Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m10s
123 lines
5.5 KiB
React
123 lines
5.5 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { IconX, IconPlayerPlay, IconPlayerPause, IconPlugConnected, IconCopy, IconTrash, IconClock } from '@tabler/icons-react';
|
|
|
|
function WsUpdateModal({ show, url, onClose }) {
|
|
const [rawMessages, setRawMessages] = useState([]);
|
|
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error
|
|
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 : '[binary]';
|
|
// Пробуем распарсить JSON после префикса "$ "
|
|
let parsed = null;
|
|
let clean = text;
|
|
if (text.startsWith('$ ')) {
|
|
const jsonPart = text.slice(2);
|
|
try {
|
|
parsed = JSON.parse(jsonPart);
|
|
clean = parsed?.line || parsed?.event || jsonPart;
|
|
if (parsed?.event === 'start' && parsed?.ts) {
|
|
setStartedAt(new Date(parsed.ts));
|
|
}
|
|
} catch {}
|
|
}
|
|
setRawMessages((prev) => [...prev, { text: clean, json: parsed }]);
|
|
};
|
|
ws.onerror = () => setStatus('error');
|
|
ws.onclose = () => setStatus('closed');
|
|
} catch (e) {
|
|
setStatus('error');
|
|
}
|
|
return () => {
|
|
try { wsRef.current?.close(); } catch {}
|
|
wsRef.current = null;
|
|
};
|
|
}, [show, url]);
|
|
|
|
useEffect(() => {
|
|
if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [rawMessages, show, 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;
|
|
|
|
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">
|
|
<h5 className="modal-title d-flex align-items-center">
|
|
<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="btn-list">
|
|
<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 btn-close" onClick={onClose}><IconX size={16} /></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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default WsUpdateModal;
|
|
|
|
|