feat(shared-sqs): queue CRUD + message peek/send/purge in UI (v0.1.6)
- admin.go: 5 new endpoints: create/delete queue, peek/send/purge messages - index.html: expandable message rows, send modal, msg detail modal, create queue modal - deployment.yaml: update image to naeel/shared-sqs:v0.1.6 - Docker image pushed: naeel/shared-sqs:v0.1.6
This commit is contained in:
@@ -3,8 +3,9 @@
|
||||
app/ui/index.html
|
||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||
Created: 2026-04-10
|
||||
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
|
||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
|
||||
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
|
||||
-->
|
||||
<html lang="ru">
|
||||
<head>
|
||||
@@ -313,6 +314,12 @@ tbody tr { cursor: pointer; }
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Expandable message rows */
|
||||
td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
||||
.msg-expand-inner { padding: 12px 20px; background: var(--bg-page); }
|
||||
.queue-name-link { cursor: pointer; color: var(--accent); }
|
||||
.queue-name-link:hover { color: var(--accent-hover); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -377,11 +384,68 @@ tbody tr { cursor: pointer; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SEND MESSAGE MODAL ===== -->
|
||||
<div id="modal-send" class="modal-overlay hidden" onclick="if(event.target===this)closeSendModal()">
|
||||
<div class="modal">
|
||||
<h2>Отправить сообщение</h2>
|
||||
<div class="form-group">
|
||||
<label for="send-body">Тело сообщения</label>
|
||||
<textarea id="send-body" rows="6" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div id="send-error" class="login-error"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" onclick="sendMessage()">Отправить</button>
|
||||
<button class="btn btn-logout" onclick="closeSendModal()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MESSAGE DETAIL MODAL ===== -->
|
||||
<div id="modal-msg-detail" class="modal-overlay hidden" onclick="if(event.target===this)closeMsgDetail()">
|
||||
<div class="modal" style="width:600px;max-width:90vw">
|
||||
<h2>Сообщение</h2>
|
||||
<div class="form-group">
|
||||
<label>ID</label>
|
||||
<input id="msg-detail-id" readonly onclick="copyField(this)" style="font-family:monospace;font-size:12px;cursor:pointer">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Body (нажмите, чтобы скопировать)</label>
|
||||
<textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Отправлено</label>
|
||||
<input id="msg-detail-sent" readonly>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" onclick="closeMsgDetail()">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== CREATE QUEUE MODAL ===== -->
|
||||
<div id="modal-queue-create" class="modal-overlay hidden" onclick="if(event.target===this)closeQueueModal()">
|
||||
<div class="modal">
|
||||
<h2>Создать очередь</h2>
|
||||
<div class="form-group">
|
||||
<label for="cq-name">Имя очереди</label>
|
||||
<input id="cq-name" placeholder="my-queue">
|
||||
</div>
|
||||
<div id="cq-error" class="login-error"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" onclick="createQueue()">Создать</button>
|
||||
<button class="btn btn-logout" onclick="closeQueueModal()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== STATE =====
|
||||
let BASE = '';
|
||||
let refreshTimer = null;
|
||||
let currentTenantId = null;
|
||||
let msgCache = {}; // id → объект сообщения для detail modal
|
||||
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
||||
let _createQueueState = { tenantId: null }; // контекст createQueue
|
||||
|
||||
// ===== INIT =====
|
||||
// Запуск — сразу показываем dashboard без логина
|
||||
@@ -543,7 +607,10 @@ function renderTenant(tenant, queues) {
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<h2>Очереди</h2>
|
||||
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@@ -555,20 +622,35 @@ function renderTenant(tenant, queues) {
|
||||
<th>Visibility Timeout</th>
|
||||
<th>Max Size</th>
|
||||
<th>Retention</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${(queues || []).map(q => `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
<td>
|
||||
<span class="queue-name-link" onclick="toggleMessages('${esc(tenant.id)}','${esc(q.name)}')">
|
||||
<span id="qicon-${esc(q.name)}">▶</span> <strong>${esc(q.name)}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="badge badge-count">${q.messages}</span></td>
|
||||
<td><span class="badge badge-count">${q.not_visible}</span></td>
|
||||
<td>${q.visibility_timeout}с</td>
|
||||
<td>${fmtBytes(q.max_message_size)}</td>
|
||||
<td>${fmtDuration(q.retention_period)}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-sm" onclick="openSendModal('${esc(tenant.id)}','${esc(q.name)}')" title="Отправить сообщение">📨</button>
|
||||
<button class="btn btn-logout btn-sm" onclick="purgeQueueConfirm('${esc(tenant.id)}','${esc(q.name)}')" style="margin-left:4px" title="Очистить очередь">🗑</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteQueueConfirm('${esc(tenant.id)}','${esc(q.name)}')" style="margin-left:4px" title="Удалить очередь">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="msgs-${esc(q.name)}" class="hidden">
|
||||
<td colspan="7" class="msg-expand">
|
||||
<div id="msgs-inner-${esc(q.name)}" class="msg-expand-inner"></div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${(!queues || queues.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей — создайте через AWS CLI</td></tr>' : ''}
|
||||
${(!queues || queues.length === 0) ? '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей</td></tr>' : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -668,6 +750,183 @@ function fmtDuration(s) {
|
||||
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
||||
return s + 'с';
|
||||
}
|
||||
|
||||
// copyTextarea — копирует содержимое textarea в буфер обмена
|
||||
function copyTextarea(el) {
|
||||
navigator.clipboard.writeText(el.value).then(() => {
|
||||
const ok = document.createElement('span');
|
||||
ok.className = 'copy-ok';
|
||||
ok.textContent = '✓ скопировано';
|
||||
el.parentNode.appendChild(ok);
|
||||
setTimeout(() => ok.remove(), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== QUEUE CRUD =====
|
||||
// openCreateQueueModal — показывает модалку создания очереди для тенанта
|
||||
function openCreateQueueModal(tenantId) {
|
||||
_createQueueState.tenantId = tenantId;
|
||||
document.getElementById('cq-name').value = '';
|
||||
document.getElementById('cq-error').textContent = '';
|
||||
document.getElementById('modal-queue-create').classList.remove('hidden');
|
||||
document.getElementById('cq-name').focus();
|
||||
}
|
||||
|
||||
function closeQueueModal() {
|
||||
document.getElementById('modal-queue-create').classList.add('hidden');
|
||||
}
|
||||
|
||||
// createQueue — POST /tenants/{id}/queues — создаёт очередь
|
||||
function createQueue() {
|
||||
const name = document.getElementById('cq-name').value.trim();
|
||||
if (!name) {
|
||||
document.getElementById('cq-error').textContent = 'Укажите имя очереди';
|
||||
return;
|
||||
}
|
||||
api('/tenants/' + _createQueueState.tenantId + '/queues', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: name })
|
||||
}).then(() => {
|
||||
closeQueueModal();
|
||||
if (currentTenantId) loadTenant(currentTenantId);
|
||||
}).catch(err => {
|
||||
document.getElementById('cq-error').textContent = 'Ошибка: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
// deleteQueueConfirm — DELETE /tenants/{id}/queues/{name} с подтверждением
|
||||
function deleteQueueConfirm(tenantId, queueName) {
|
||||
if (!confirm('Удалить очередь "' + queueName + '"?\nВсе сообщения будут потеряны.')) return;
|
||||
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName), { method: 'DELETE' })
|
||||
.then(() => { if (currentTenantId) loadTenant(currentTenantId); })
|
||||
.catch(err => alert('Ошибка удаления: ' + err.message));
|
||||
}
|
||||
|
||||
// ===== MESSAGE PEEK / SEND / PURGE =====
|
||||
// toggleMessages — разворачивает/сворачивает inline-таблицу сообщений очереди
|
||||
function toggleMessages(tenantId, queueName) {
|
||||
const row = document.getElementById('msgs-' + queueName);
|
||||
const icon = document.getElementById('qicon-' + queueName);
|
||||
if (!row) return;
|
||||
if (row.classList.contains('hidden')) {
|
||||
row.classList.remove('hidden');
|
||||
if (icon) icon.textContent = '▼';
|
||||
loadMessages(tenantId, queueName);
|
||||
} else {
|
||||
row.classList.add('hidden');
|
||||
if (icon) icon.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
// loadMessages — GET /tenants/{id}/queues/{q}/messages и рендерит таблицу
|
||||
function loadMessages(tenantId, queueName) {
|
||||
const inner = document.getElementById('msgs-inner-' + queueName);
|
||||
if (!inner) return;
|
||||
inner.innerHTML = '<span style="color:var(--text-secondary);font-size:13px">Загрузка...</span>';
|
||||
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages')
|
||||
.then(msgs => renderQueueMessages(queueName, msgs))
|
||||
.catch(err => {
|
||||
const el = document.getElementById('msgs-inner-' + queueName);
|
||||
if (el) el.innerHTML = '<span style="color:var(--danger);font-size:13px">Ошибка: ' + esc(err.message) + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// renderQueueMessages — рендерит таблицу сообщений в expandable row
|
||||
function renderQueueMessages(queueName, msgs) {
|
||||
const inner = document.getElementById('msgs-inner-' + queueName);
|
||||
if (!inner) return;
|
||||
if (!msgs || msgs.length === 0) {
|
||||
inner.innerHTML = '<span style="color:var(--text-secondary);font-size:13px">Очередь пуста</span>';
|
||||
return;
|
||||
}
|
||||
// Сохраняем в кеш для detail modal
|
||||
msgs.forEach(m => { msgCache[m.id] = m; });
|
||||
inner.innerHTML = `
|
||||
<table style="width:100%;font-size:13px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">ID</th>
|
||||
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Body</th>
|
||||
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Отправлено</th>
|
||||
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Получений</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${msgs.map(m => `
|
||||
<tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer"
|
||||
onmouseover="this.style.background='rgba(26,127,212,0.08)'" onmouseout="this.style.background=''">
|
||||
<td style="padding:6px 12px;font-family:monospace;color:var(--text-secondary)">${esc(m.id).substring(0,8)}…</td>
|
||||
<td style="padding:6px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.body || '').substring(0,120)}${(m.body||'').length>120?'…':''}</td>
|
||||
<td style="padding:6px 12px;color:var(--text-secondary)">${fmtDate(m.sent)}</td>
|
||||
<td style="padding:6px 12px">${m.receives}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// openMsgDetail — показывает модалку с полным телом сообщения
|
||||
function openMsgDetail(id) {
|
||||
const m = msgCache[id];
|
||||
if (!m) return;
|
||||
document.getElementById('msg-detail-id').value = m.id || '';
|
||||
document.getElementById('msg-detail-body').value = m.body || '';
|
||||
document.getElementById('msg-detail-sent').value = m.sent ? fmtDate(m.sent) : '';
|
||||
document.getElementById('modal-msg-detail').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeMsgDetail() {
|
||||
document.getElementById('modal-msg-detail').classList.add('hidden');
|
||||
}
|
||||
|
||||
// openSendModal — открывает модалку отправки сообщения
|
||||
function openSendModal(tenantId, queueName) {
|
||||
_sendState.tenantId = tenantId;
|
||||
_sendState.queueName = queueName;
|
||||
document.getElementById('send-body').value = '';
|
||||
document.getElementById('send-error').textContent = '';
|
||||
document.getElementById('modal-send').classList.remove('hidden');
|
||||
document.getElementById('send-body').focus();
|
||||
}
|
||||
|
||||
function closeSendModal() {
|
||||
document.getElementById('modal-send').classList.add('hidden');
|
||||
}
|
||||
|
||||
// sendMessage — POST /tenants/{id}/queues/{q}/messages — отправляет сообщение
|
||||
function sendMessage() {
|
||||
const body = document.getElementById('send-body').value.trim();
|
||||
if (!body) {
|
||||
document.getElementById('send-error').textContent = 'Введите тело сообщения';
|
||||
return;
|
||||
}
|
||||
const { tenantId, queueName } = _sendState;
|
||||
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body: body })
|
||||
}).then(() => {
|
||||
closeSendModal();
|
||||
// Если очередь раскрыта — перезагрузить сообщения
|
||||
const row = document.getElementById('msgs-' + queueName);
|
||||
if (row && !row.classList.contains('hidden')) loadMessages(tenantId, queueName);
|
||||
if (currentTenantId) loadTenant(currentTenantId);
|
||||
}).catch(err => {
|
||||
document.getElementById('send-error').textContent = 'Ошибка: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
// purgeQueueConfirm — DELETE /tenants/{id}/queues/{q}/messages с подтверждением
|
||||
function purgeQueueConfirm(tenantId, queueName) {
|
||||
if (!confirm('Очистить очередь "' + queueName + '"?\nВсе сообщения будут удалены.')) return;
|
||||
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages', { method: 'DELETE' })
|
||||
.then(() => {
|
||||
const row = document.getElementById('msgs-' + queueName);
|
||||
if (row && !row.classList.contains('hidden')) loadMessages(tenantId, queueName);
|
||||
if (currentTenantId) loadTenant(currentTenantId);
|
||||
})
|
||||
.catch(err => alert('Ошибка: ' + err.message));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user