Files
sless/shared-sqs/app/ui/index.html
T
“Naeel” 12b3bb9bf3 feat(shared-sqs): add SQS Console UI (v0.1.4)
- Embedded SPA with Nubes branding (go:embed)
- Admin dashboard: health stats, tenant CRUD, queue listing
- New API: GET /admin/tenants/{id}/queues — tenant queue list with counts
- Route /ui/ serves built-in console
- Auto-refresh every 10s, responsive design
2026-04-09 18:01:12 +03:00

727 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>
<!--
app/ui/index.html
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
Created: 2026-04-10
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
-->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SQS Console — Nubes</title>
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style>
/* Nubes palette — идентично iot.kube5s.ru/console */
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001C34;
--border: #0b2d50;
--accent: #1a7fd4;
--accent-hover: #2196f3;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
--danger: #e74c3c;
--success: #27ae60;
--warning: #f39c12;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* Navbar */
.navbar {
background: var(--bg-navbar);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-brand img {
height: 28px;
filter: brightness(0) invert(1);
}
.navbar-brand span {
font-size: 16px;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--text-primary);
}
.navbar-user {
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
color: var(--text-secondary);
}
.btn-logout {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
/* Container */
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
/* Cards */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 16px;
}
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 4px;
}
/* Table */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
thead th {
text-align: left;
padding: 12px 16px;
border-bottom: 2px solid var(--border);
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
tbody tr:hover { background: rgba(26, 127, 212, 0.05); }
tbody tr { cursor: pointer; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
/* Forms */
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-group input {
width: 100%;
padding: 10px 14px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
/* Login page */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px;
width: 400px;
text-align: center;
}
.login-box img { height: 40px; filter: brightness(0) invert(1); margin-bottom: 12px; }
.login-box h1 {
font-size: 18px;
letter-spacing: 2px;
margin-bottom: 32px;
color: var(--text-primary);
}
.login-error {
color: var(--danger);
font-size: 13px;
margin-bottom: 12px;
min-height: 20px;
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-active { background: rgba(39,174,96,0.15); color: var(--success); }
.badge-inactive { background: rgba(231,76,60,0.15); color: var(--danger); }
.badge-count {
background: rgba(26,127,212,0.15);
color: var(--accent);
min-width: 24px;
text-align: center;
}
/* Breadcrumb */
.breadcrumb {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 20px;
}
.breadcrumb a { color: var(--accent); }
.breadcrumb span { margin: 0 8px; }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
width: 480px;
max-width: 90vw;
}
.modal h2 {
font-size: 18px;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
/* Hidden helper */
.hidden { display: none !important; }
/* Responsive */
@media (max-width: 640px) {
.container { padding: 12px; }
.login-box { width: 95vw; padding: 24px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
/* Copying animation */
.copy-ok {
color: var(--success);
font-size: 12px;
margin-left: 8px;
animation: fadeout 1.5s forwards;
}
@keyframes fadeout { 0%{opacity:1} 70%{opacity:1} 100%{opacity:0} }
/* Refresh */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.toolbar h2 {
font-size: 18px;
font-weight: 600;
}
.auto-refresh {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<!-- ===== LOGIN ===== -->
<div id="login-page" class="login-page">
<div class="login-box">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1>
<div id="login-error" class="login-error"></div>
<div class="form-group">
<label for="token-input">Admin Token</label>
<input type="password" id="token-input" placeholder="sqs-admin-..." autocomplete="off">
</div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
</div>
</div>
<!-- ===== APP SHELL ===== -->
<div id="app" class="hidden">
<nav class="navbar">
<div class="navbar-brand">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<span>SQS CONSOLE</span>
</div>
<div class="navbar-user">
<span>admin</span>
<button class="btn-logout" onclick="doLogout()">Выход</button>
</div>
</nav>
<div class="container">
<!-- Dashboard view -->
<div id="view-dashboard"></div>
<!-- Tenant detail view -->
<div id="view-tenant" class="hidden"></div>
</div>
</div>
<!-- ===== CREATE TENANT MODAL ===== -->
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()">
<div class="modal">
<h2>Создать тенанта</h2>
<div class="form-group">
<label for="ct-name">Имя</label>
<input id="ct-name" placeholder="my-service">
</div>
<div class="form-group">
<label for="ct-queues">Макс. очередей</label>
<input id="ct-queues" type="number" value="10" min="1" max="1000">
</div>
<div id="ct-error" class="login-error"></div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="createTenant()">Создать</button>
<button class="btn btn-logout" onclick="closeModal()">Отмена</button>
</div>
</div>
</div>
<!-- ===== CREDENTIALS MODAL ===== -->
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal">
<h2>Тенант создан</h2>
<p style="color:var(--warning);font-size:13px;margin-bottom:16px">
⚠ Сохраните credentials — Secret Key показывается только один раз.
</p>
<div class="form-group">
<label>Access Key</label>
<input id="creds-ak" readonly onclick="copyField(this)">
</div>
<div class="form-group">
<label>Secret Key</label>
<input id="creds-sk" readonly onclick="copyField(this)">
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeCredsModal()">Готово</button>
</div>
</div>
</div>
<script>
// ===== STATE =====
let TOKEN = '';
let BASE = '';
let refreshTimer = null;
let currentTenantId = null;
// ===== AUTH =====
// doLogin — проверяет токен через /admin/health и сохраняет в sessionStorage
function doLogin() {
const token = document.getElementById('token-input').value.trim();
if (!token) return;
// Определяем base URL — тот же origin что и UI
BASE = window.location.origin;
TOKEN = token;
api('/admin/health').then(data => {
sessionStorage.setItem('sqs_token', token);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
showDashboard();
}).catch(err => {
document.getElementById('login-error').textContent = 'Неверный токен';
TOKEN = '';
});
}
// doLogout — очищает сессию и возвращает на логин
function doLogout() {
TOKEN = '';
sessionStorage.removeItem('sqs_token');
if (refreshTimer) clearInterval(refreshTimer);
document.getElementById('app').classList.add('hidden');
document.getElementById('login-page').classList.remove('hidden');
document.getElementById('token-input').value = '';
document.getElementById('login-error').textContent = '';
}
// Авто-логин из sessionStorage
(function autoLogin() {
const saved = sessionStorage.getItem('sqs_token');
if (saved) {
document.getElementById('token-input').value = saved;
doLogin();
}
// Enter на поле токена
document.getElementById('token-input').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
})();
// ===== API HELPER =====
// api — выполняет запрос к admin API с bearer token
function api(path, opts = {}) {
return fetch(BASE + path, {
...opts,
headers: {
'Authorization': 'Bearer ' + TOKEN,
'Content-Type': 'application/json',
...(opts.headers || {})
}
}).then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
if (r.status === 204) return null;
return r.json();
});
}
// ===== DASHBOARD =====
// showDashboard — загружает health + tenants и рендерит главную страницу
function showDashboard() {
currentTenantId = null;
document.getElementById('view-tenant').classList.add('hidden');
document.getElementById('view-dashboard').classList.remove('hidden');
if (refreshTimer) clearInterval(refreshTimer);
loadDashboard();
refreshTimer = setInterval(loadDashboard, 10000);
}
// loadDashboard — загружает данные и обновляет DOM
function loadDashboard() {
Promise.all([api('/admin/health'), api('/admin/tenants')])
.then(([health, tenants]) => renderDashboard(health, tenants))
.catch(err => console.error('Dashboard load error:', err));
}
// renderDashboard — рендерит статистику и таблицу тенантов
function renderDashboard(health, tenants) {
const el = document.getElementById('view-dashboard');
el.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${health.tenant_count || 0}</div>
<div class="stat-label">Тенанты</div>
</div>
<div class="stat-card">
<div class="stat-value">${health.queue_count || 0}</div>
<div class="stat-label">Очереди</div>
</div>
<div class="stat-card">
<div class="stat-value">${health.message_count || 0}</div>
<div class="stat-label">Сообщения</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:var(--success)">●</div>
<div class="stat-label">${health.status || 'ok'}</div>
</div>
</div>
<div class="card">
<div class="toolbar">
<h2>Тенанты</h2>
<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="openModal()">+ Создать</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>Access Key</th>
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
${(tenants || []).map(t => `
<tr onclick="showTenant('${esc(t.id)}')">
<td><strong>${esc(t.name)}</strong></td>
<td style="font-family:monospace;font-size:12px">${esc(t.access_key)}</td>
<td>${t.max_queues}</td>
<td>${t.active
? '<span class="badge badge-active">active</span>'
: '<span class="badge badge-inactive">inactive</span>'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteTenant('${esc(t.id)}','${esc(t.name)}')">✕</button>
</td>
</tr>
`).join('')}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет тенантов</td></tr>' : ''}
</tbody>
</table>
</div>
</div>
`;
}
// ===== TENANT DETAIL =====
// showTenant — переключает вид на детали тенанта и его очереди
function showTenant(id) {
currentTenantId = id;
document.getElementById('view-dashboard').classList.add('hidden');
document.getElementById('view-tenant').classList.remove('hidden');
if (refreshTimer) clearInterval(refreshTimer);
loadTenant(id);
refreshTimer = setInterval(() => loadTenant(id), 10000);
}
// loadTenant — загружает тенанта и его очереди
function loadTenant(id) {
Promise.all([api('/admin/tenants/' + id), api('/admin/tenants/' + id + '/queues')])
.then(([tenant, queues]) => renderTenant(tenant, queues))
.catch(err => {
console.error('Tenant load error:', err);
showDashboard();
});
}
// renderTenant — рендерит детали тенанта: credentials, список очередей
function renderTenant(tenant, queues) {
const el = document.getElementById('view-tenant');
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
el.innerHTML = `
<div class="breadcrumb">
<a href="#" onclick="event.preventDefault();showDashboard()">Тенанты</a>
<span></span>
${esc(tenant.name)}
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${(queues || []).length}</div>
<div class="stat-label">Очереди</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalMsgs}</div>
<div class="stat-label">Сообщения</div>
</div>
<div class="stat-card">
<div class="stat-value">${tenant.max_queues}</div>
<div class="stat-label">Лимит очередей</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:20px;font-family:monospace">${esc(tenant.access_key)}</div>
<div class="stat-label">Access Key</div>
</div>
</div>
<div class="card">
<div class="toolbar">
<h2>Очереди</h2>
<div class="auto-refresh"><span>⟳ 10с</span></div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя очереди</th>
<th>Сообщения</th>
<th>In-flight</th>
<th>Visibility Timeout</th>
<th>Max Size</th>
<th>Retention</th>
</tr>
</thead>
<tbody>
${(queues || []).map(q => `
<tr>
<td><strong>${esc(q.name)}</strong></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>
</tr>
`).join('')}
${(!queues || queues.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей — создайте через AWS CLI</td></tr>' : ''}
</tbody>
</table>
</div>
</div>
`;
}
// ===== TENANT CRUD =====
// openModal — показывает модалку создания тенанта
function openModal() {
document.getElementById('ct-name').value = '';
document.getElementById('ct-queues').value = '10';
document.getElementById('ct-error').textContent = '';
document.getElementById('modal-create').classList.remove('hidden');
document.getElementById('ct-name').focus();
}
function closeModal() {
document.getElementById('modal-create').classList.add('hidden');
}
// createTenant — POST /admin/tenants, показывает credentials
function createTenant() {
const name = document.getElementById('ct-name').value.trim();
const maxQ = parseInt(document.getElementById('ct-queues').value) || 10;
if (!name) {
document.getElementById('ct-error').textContent = 'Укажите имя';
return;
}
api('/admin/tenants', {
method: 'POST',
body: JSON.stringify({ name: name, max_queues: maxQ })
}).then(data => {
closeModal();
// Показываем credentials
document.getElementById('creds-ak').value = data.access_key || '';
document.getElementById('creds-sk').value = data.secret_key || '';
document.getElementById('modal-creds').classList.remove('hidden');
loadDashboard();
}).catch(err => {
document.getElementById('ct-error').textContent = 'Ошибка: ' + err.message;
});
}
function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden');
}
// deleteTenant — DELETE /admin/tenants/{id} с подтверждением
function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
api('/admin/tenants/' + id, { method: 'DELETE' })
.then(() => loadDashboard())
.catch(err => alert('Ошибка удаления: ' + err.message));
}
// ===== HELPERS =====
// esc — экранирование HTML для защиты от XSS
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// copyField — копирует значение input в буфер обмена
function copyField(input) {
navigator.clipboard.writeText(input.value).then(() => {
const ok = document.createElement('span');
ok.className = 'copy-ok';
ok.textContent = '✓ скопировано';
input.parentNode.appendChild(ok);
setTimeout(() => ok.remove(), 1500);
});
}
// fmtDate — форматирует дату для таблицы
function fmtDate(s) {
if (!s) return '—';
const d = new Date(s);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
// fmtBytes — форматирует байты (262144 → 256 KB)
function fmtBytes(b) {
if (!b) return '—';
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB';
if (b >= 1024) return (b / 1024).toFixed(0) + ' KB';
return b + ' B';
}
// fmtDuration — форматирует секунды (345600 → 4д)
function fmtDuration(s) {
if (!s) return '—';
if (s >= 86400) return (s / 86400).toFixed(0) + 'д';
if (s >= 3600) return (s / 3600).toFixed(0) + 'ч';
if (s >= 60) return (s / 60).toFixed(0) + 'м';
return s + 'с';
}
</script>
</body>
</html>