nats-ui/index.html
admin f4f3062352
Some checks failed
CI/CD / Lint & Test (push) Failing after 51s
CI/CD / Build & Push Docker image (hub.p42.ru) (push) Has been skipped
Update architecture
2026-02-09 17:31:21 +07:00

656 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NATS Queue Visualizer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
font-size: 0.95em;
}
.header .status {
display: inline-block;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 0.9em;
}
.header-sep {
opacity: 0.7;
}
.header-info strong {
font-weight: 600;
}
.status.connected {
background: rgba(76, 175, 80, 0.3);
}
.status.disconnected {
background: rgba(244, 67, 54, 0.3);
}
.controls {
padding: 20px 30px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
flex: 1;
min-width: 300px;
}
.filter-group label {
font-weight: 600;
color: #555;
}
select, input {
padding: 10px 15px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
select:focus, input:focus {
outline: none;
border-color: #667eea;
}
select {
flex: 1;
min-width: 200px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover {
background: #d32f2f;
}
.stats {
display: flex;
gap: 20px;
padding: 20px 30px;
background: #f9f9f9;
border-bottom: 1px solid #ddd;
}
.stat-item {
flex: 1;
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #667eea;
}
.stat-label {
color: #666;
font-size: 0.9em;
margin-top: 5px;
}
.messages-container {
padding: 20px 30px;
max-height: 600px;
overflow-y: auto;
}
.message {
background: #f9f9f9;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 15px;
border-radius: 6px;
transition: all 0.3s;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.message:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateX(5px);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 10px;
}
.message-subject {
font-weight: 600;
color: #667eea;
font-size: 1.1em;
}
.message-meta {
display: flex;
gap: 15px;
font-size: 0.85em;
color: #666;
}
.message-data {
background: white;
padding: 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
border: 1px solid #e0e0e0;
}
.message-size {
display: inline-block;
padding: 4px 8px;
background: #e3f2fd;
border-radius: 4px;
font-size: 0.85em;
color: #1976d2;
}
.message-producer-consumer {
display: flex;
gap: 20px;
margin-top: 8px;
font-size: 0.85em;
color: #555;
}
.message-producer-consumer span {
display: flex;
align-items: center;
gap: 6px;
}
.message-producer-consumer strong {
color: #333;
}
.connections-section {
padding: 20px 30px;
border-bottom: 1px solid #eee;
}
.connections-section h2 {
font-size: 1.3em;
margin-bottom: 15px;
color: #333;
}
.connections-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.connections-table th,
.connections-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.connections-table th {
background: #f5f5f5;
font-weight: 600;
color: #555;
}
.connections-table tr:hover {
background: #fafafa;
}
.connections-empty {
color: #999;
padding: 20px;
text-align: center;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state svg {
width: 100px;
height: 100px;
margin-bottom: 20px;
opacity: 0.3;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
.search-box {
flex: 1;
min-width: 200px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 NATS Queue Visualizer</h1>
<div class="header-info">
<span id="status" class="status disconnected">Отключено</span>
<span class="header-sep"></span>
<span>Подписки: <strong id="subscribedList"></strong></span>
</div>
</div>
<div class="controls">
<div class="filter-group">
<label>Subject:</label>
<select id="subjectFilter">
<option value="">Все subjects</option>
</select>
</div>
<div class="filter-group search-box">
<label>Поиск:</label>
<input type="text" id="searchInput" placeholder="Поиск по содержимому...">
</div>
<button class="btn btn-danger" onclick="clearMessages()">Очистить</button>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="totalMessages">0</div>
<div class="stat-label">Всего сообщений</div>
</div>
<div class="stat-item">
<div class="stat-value" id="uniqueSubjects">0</div>
<div class="stat-label">Уникальных subjects</div>
</div>
<div class="stat-item">
<div class="stat-value" id="totalBytes">0</div>
<div class="stat-label">Всего байт</div>
</div>
</div>
<div class="connections-section">
<h2>Активные подключения к NATS</h2>
<div id="connectionsContainer">
<div class="connections-empty">Загрузка…</div>
</div>
</div>
<div class="messages-container" id="messagesContainer">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<p>Ожидание сообщений из NATS...</p>
</div>
</div>
</div>
<script>
let ws = null;
let allMessages = [];
let currentFilter = '';
let currentSearch = '';
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
updateStatus(true);
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Проверяем, массив ли это (все сообщения при подключении) или одно сообщение
if (Array.isArray(data)) {
// При подключении сервер отправляет все существующие сообщения массивом
allMessages = data.reverse(); // Переворачиваем, чтобы новые были сверху
updateMessages();
updateStats();
updateSubjectFilter();
console.log(`Loaded ${data.length} existing messages`);
} else if (data.id && data.subject) {
// Одно новое сообщение в реальном времени
addMessage(data);
}
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateStatus(false);
};
ws.onclose = () => {
updateStatus(false);
console.log('WebSocket disconnected');
// Переподключение через 3 секунды
setTimeout(connectWebSocket, 3000);
};
}
function updateStatus(connected) {
const statusEl = document.getElementById('status');
if (connected) {
statusEl.textContent = 'Подключено';
statusEl.className = 'status connected';
} else {
statusEl.textContent = 'Отключено';
statusEl.className = 'status disconnected';
}
}
function addMessage(message) {
allMessages.unshift(message); // Добавляем в начало
updateMessages();
updateStats();
updateSubjectFilter(); // Автоматически обновляем список subjects
}
function updateMessages() {
const container = document.getElementById('messagesContainer');
let filtered = allMessages;
if (currentFilter) {
filtered = filtered.filter(m => m.subject === currentFilter);
}
if (currentSearch) {
const searchLower = currentSearch.toLowerCase();
filtered = filtered.filter(m =>
m.data.toLowerCase().includes(searchLower) ||
m.subject.toLowerCase().includes(searchLower)
);
}
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>Нет сообщений, соответствующих фильтрам</p>
</div>
`;
return;
}
container.innerHTML = filtered.map(msg => `
<div class="message">
<div class="message-header">
<span class="message-subject">${escapeHtml(msg.subject)}</span>
<div class="message-meta">
<span>${formatTime(msg.timestamp)}</span>
<span class="message-size">${formatBytes(msg.size)}</span>
</div>
</div>
<div class="message-producer-consumer">
<span><strong>Продюсер:</strong> ${escapeHtml(msg.producer || '—')}</span>
<span><strong>Потребитель:</strong> ${escapeHtml(msg.consumer || 'nats-ui')}</span>
</div>
<div class="message-data">${escapeHtml(msg.data)}</div>
</div>
`).join('');
}
function updateStats() {
document.getElementById('totalMessages').textContent = allMessages.length;
const uniqueSubjects = new Set(allMessages.map(m => m.subject));
document.getElementById('uniqueSubjects').textContent = uniqueSubjects.size;
const totalBytes = allMessages.reduce((sum, m) => sum + m.size, 0);
document.getElementById('totalBytes').textContent = formatBytes(totalBytes);
}
function updateSubjectFilter() {
const subjects = [...new Set(allMessages.map(m => m.subject))];
const select = document.getElementById('subjectFilter');
const currentValue = select.value;
select.innerHTML = '<option value="">Все subjects</option>' +
subjects.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
if (currentValue && subjects.includes(currentValue)) {
select.value = currentValue;
}
}
function refreshMessages() {
fetch('/api/messages')
.then(res => res.json())
.then(messages => {
allMessages = messages.reverse(); // Переворачиваем, чтобы новые были сверху
updateSubjectFilter();
updateMessages();
updateStats();
})
.catch(err => console.error('Error fetching messages:', err));
}
function clearMessages() {
if (confirm('Вы уверены, что хотите очистить все сообщения?')) {
allMessages = [];
updateMessages();
updateStats();
}
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Обработчики событий
document.getElementById('subjectFilter').addEventListener('change', (e) => {
currentFilter = e.target.value;
updateMessages();
});
document.getElementById('searchInput').addEventListener('input', (e) => {
currentSearch = e.target.value;
updateMessages();
});
function loadSubscribed() {
fetch('/api/subscribed')
.then(res => res.json())
.then(data => {
const list = data.subscribed || [];
const el = document.getElementById('subscribedList');
el.textContent = list.length ? list.join(', ') : '—';
})
.catch(() => {
document.getElementById('subscribedList').textContent = '—';
});
}
function loadConnections() {
fetch('/api/connections')
.then(res => res.json())
.then(data => {
const container = document.getElementById('connectionsContainer');
const conns = data.connections || [];
if (conns.length === 0) {
container.innerHTML = '<div class="connections-empty">Нет данных о подключениях. Укажите nats_monitor_url в конфигурации (HTTP мониторинг NATS, порт 8222).</div>';
return;
}
container.innerHTML = `
<table class="connections-table">
<thead>
<tr>
<th>CID</th>
<th>Имя</th>
<th>IP:Порт</th>
<th>Подписок</th>
<th>Входящие</th>
<th>Исходящие</th>
<th>Язык</th>
</tr>
</thead>
<tbody>
${conns.map(c => `
<tr>
<td>${c.cid}</td>
<td>${escapeHtml(c.name || '—')}</td>
<td>${escapeHtml(c.ip || '')}:${c.port || ''}</td>
<td>${c.subscriptions ?? '—'}</td>
<td>${c.in_msgs ?? '—'}</td>
<td>${c.out_msgs ?? '—'}</td>
<td>${escapeHtml(c.lang || '—')}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
})
.catch(err => {
document.getElementById('connectionsContainer').innerHTML =
'<div class="connections-empty">Не удалось загрузить список подключений.</div>';
console.error('Connections load error:', err);
});
}
// Инициализация - только WebSocket, все сообщения придут автоматически
connectWebSocket();
loadSubscribed();
loadConnections();
setInterval(loadConnections, 5000);
</script>
</body>
</html>