nats-ui/index.html
2026-02-06 15:41:01 +07:00

504 lines
15 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 .status {
display: inline-block;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 0.9em;
margin-top: 10px;
}
.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;
}
.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 id="status" class="status disconnected">Отключено</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="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-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();
});
// Инициализация - только WebSocket, все сообщения придут автоматически
connectWebSocket();
</script>
</body>
</html>