228 lines
7.2 KiB
React
228 lines
7.2 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
IconX,
|
|
IconPlayerPlay,
|
|
IconPlayerPause,
|
|
IconPlugConnected,
|
|
IconCopy,
|
|
IconTrash,
|
|
IconClock
|
|
} from '@tabler/icons-react';
|
|
|
|
/**
|
|
* WsUpdateModal - модальное окно с логами WebSocket
|
|
* Простой подход со встроенным backdrop (как в HistoryModal)
|
|
*/
|
|
function WsUpdateModal({ show, url, onClose }) {
|
|
const [rawMessages, setRawMessages] = useState([]);
|
|
const [status, setStatus] = useState('connecting');
|
|
const wsRef = useRef(null);
|
|
const bottomRef = useRef(null);
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [startedAt, setStartedAt] = useState(null);
|
|
|
|
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 : '';
|
|
let parsed = null;
|
|
let jsonStart = text.indexOf('{');
|
|
|
|
if (jsonStart >= 0) {
|
|
const candidate = text.slice(jsonStart).trim();
|
|
try {
|
|
parsed = JSON.parse(candidate);
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
}
|
|
|
|
if (parsed) {
|
|
if (parsed.event === 'start' && parsed.ts) {
|
|
setStartedAt(new Date(parsed.ts));
|
|
return;
|
|
}
|
|
if (typeof parsed.line === 'string' && parsed.line.trim().length > 0) {
|
|
setRawMessages((prev) => [...prev, { text: parsed.line, json: parsed }]);
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 { /* no-op */ }
|
|
wsRef.current = null;
|
|
};
|
|
}, [show, url]);
|
|
|
|
useEffect(() => {
|
|
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 elapsedMs = useMemo(() => {
|
|
if (!startedAt || rawMessages.length === 0) return null;
|
|
const lastTs = Date.now();
|
|
return lastTs - startedAt.getTime();
|
|
}, [startedAt, rawMessages]);
|
|
|
|
if (!show) return null;
|
|
|
|
const copyLog = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(plainLog);
|
|
} catch { /* no-op */ }
|
|
};
|
|
|
|
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>
|
|
);
|
|
};
|
|
|
|
const handleBackdropClick = (e) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose?.();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Modal Backdrop */}
|
|
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
|
|
|
{/* Modal */}
|
|
<div
|
|
className="modal show d-block"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
|
>
|
|
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
|
<div className="modal-content" tabIndex={-1}>
|
|
<div className="modal-header">
|
|
<h5 className="modal-title d-flex align-items-center">
|
|
<IconPlugConnected className="me-2" size={24} />
|
|
Логи запуска
|
|
{getStatusBadge()}
|
|
</h5>
|
|
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
|
|
</div>
|
|
|
|
<div className="modal-body">
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
</div>
|
|
|
|
<div className="modal-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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default WsUpdateModal;
|