Files
sless/shared-sqs/app/ui/index.html
T
“Naeel” 70c276b4e7 feat(shared-sqs): remove auth from UI, add public /ui/api/ routes (v0.1.5)
- Public routes /ui/api/* without bearer token for demo
- UI loads dashboard immediately, no login required
- Admin API /admin/* still requires bearer token
- For demo purposes only — auth will be restored later
2026-04-09 18:23:17 +03:00

674 lines
19 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>
<!-- ===== APP SHELL (всегда видим, без логина) ===== -->
<div id="app">
<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>
</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 BASE = '';
let refreshTimer = null;
let currentTenantId = null;
// ===== INIT =====
// Запуск — сразу показываем dashboard без логина
(function init() {
BASE = window.location.origin;
showDashboard();
})();
// ===== API HELPER =====
// api — выполняет запрос к публичному UI API (без auth)
function api(path, opts = {}) {
return fetch(BASE + '/ui/api' + path, {
...opts,
headers: {
'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('/health'), api('/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('/tenants/' + id), api('/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 /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('/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 /tenants/{id} с подтверждением
function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
api('/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>