1114 lines
39 KiB
HTML
1114 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<!--
|
||
app/ui/index.html
|
||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||
Created: 2026-04-10
|
||
Updated: 2026-04-12 09:28 MSK — demo token login и UI только для собственного tenant-а
|
||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||
Режим: UI принимает либо nubes JWT, либо публичный demo token для seeded demo tenant.
|
||
-->
|
||
<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" type="image/svg+xml" href="/static/favicon.svg">
|
||
<style>
|
||
/* Nubes design system — светлая тема (deck) */
|
||
:root {
|
||
--bg-page: #fafafa;
|
||
--bg-surface: #ffffff;
|
||
--bg-navbar: #ffffff;
|
||
--border: #d1d5db;
|
||
--accent: #2563eb;
|
||
--accent-hover: #1d4ed8;
|
||
--text-primary: #1a1a1a;
|
||
--text-secondary: #6b7280;
|
||
--danger: #dc2626;
|
||
--success: #16a34a;
|
||
--warning: #d97706;
|
||
--brand-grey-light: #f3f4f6;
|
||
}
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
font-size: 14px;
|
||
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;
|
||
}
|
||
.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: 12px;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||
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: 12px;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||
padding: 20px;
|
||
text-align: center;
|
||
}
|
||
.stat-value {
|
||
font-size: 30px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
.stat-value.accent { 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: var(--brand-grey-light); }
|
||
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: #b91c1c; }
|
||
.btn-sm { padding: 4px 12px; font-size: 13px; }
|
||
|
||
/* 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: #ffffff;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
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; margin-bottom: 12px; }
|
||
.login-box h1 {
|
||
font-size: 22px;
|
||
letter-spacing: 1px;
|
||
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 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.badge-active { background: rgba(22,163,74,0.12); color: var(--success); }
|
||
.badge-inactive { background: rgba(220,38,38,0.1); color: var(--danger); }
|
||
.badge-count {
|
||
background: rgba(37,99,235,0.1);
|
||
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.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
.modal {
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
|
||
padding: 32px;
|
||
width: 560px;
|
||
max-width: 92vw;
|
||
max-height: 88vh;
|
||
overflow-y: auto;
|
||
}
|
||
.modal h2 {
|
||
font-size: 20px;
|
||
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: 20px;
|
||
font-weight: 600;
|
||
}
|
||
.auto-refresh {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Credentials modal — подсказка */
|
||
.creds-hint {
|
||
font-size: 13px;
|
||
color: #374151;
|
||
background: var(--brand-grey-light);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px 14px;
|
||
margin-bottom: 16px;
|
||
line-height: 1.6;
|
||
}
|
||
.creds-hint .warn {
|
||
color: var(--warning);
|
||
font-weight: 600;
|
||
}
|
||
.creds-hint p { margin: 6px 0; }
|
||
.creds-hint .creds-sub { font-weight: 600; margin-top: 10px; color: var(--text-primary); }
|
||
.creds-hint pre {
|
||
background: #ffffff;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 8px 10px;
|
||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
overflow-x: auto;
|
||
white-space: pre;
|
||
margin: 6px 0 10px;
|
||
}
|
||
|
||
/* 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>
|
||
|
||
<!-- ===== LOGIN PAGE ===== -->
|
||
<div id="login-page" class="login-page">
|
||
<div class="login-box">
|
||
<img src="/static/logo.svg" alt="Nubes">
|
||
<h1>SQS CONSOLE</h1>
|
||
<div class="form-group">
|
||
<label for="login-token">API Token или Demo Token</label>
|
||
<input id="login-token" type="password" placeholder="JWT или demo-ui-shared-sqs-ngcloud-2026">
|
||
</div>
|
||
<div id="login-error" class="login-error"></div>
|
||
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
|
||
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
|
||
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
|
||
Для личного аккаунта используйте API token из панели Nubes.
|
||
</p>
|
||
<div style="margin-top:24px;text-align:left;font-size:12px;color:var(--text-secondary);border-top:1px solid var(--border);padding-top:16px;line-height:1.7">
|
||
<div style="font-weight:700;color:var(--text-primary);margin-bottom:6px;letter-spacing:0.5px">Информация о сервисе</div>
|
||
<div>Endpoint: <span style="color:var(--accent);font-family:monospace">https://sqs.containerk8s.dev.nubes.ru</span></div>
|
||
<div>Health: <span style="font-family:monospace">GET /health</span> · Метрики: <span style="font-family:monospace">GET /metrics</span></div>
|
||
<div>SQS API: <span style="font-family:monospace">POST /</span> (AWS Signature V4, регион <span style="font-family:monospace">us-east-1</span>)</div>
|
||
<div>Админ-консоль: <a href="/admin">/admin</a> (вход по admin-токену)</div>
|
||
<div>Realm: <span style="font-family:monospace">iot-naeel</span> · Persistence: Managed Redis</div>
|
||
<div>Версия: <span id="svc-version" style="font-family:monospace">—</span></div>
|
||
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.35</span></div>
|
||
<div style="margin-top:8px;color:var(--warning)">Статус: тестирование</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== APP SHELL (показывается после авторизации) ===== -->
|
||
<div id="app" class="hidden">
|
||
<nav class="navbar">
|
||
<div class="navbar-brand">
|
||
<img src="/static/logo.svg" alt="Nubes">
|
||
<span>SQS CONSOLE</span>
|
||
</div>
|
||
<div class="navbar-user">
|
||
<span id="user-email" style="color:var(--accent)"></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>Credentials</h2>
|
||
<div class="creds-hint">
|
||
<p class="warn">⚠ Сохраните ключи — это креды вашего аккаунта для AWS CLI/SDK.</p>
|
||
<p><strong>Зачем:</strong> приложения подключаются к SQS API по протоколу AWS (подпись Signature V4). Токен входа в консоль для этого не подходит.</p>
|
||
<p><strong>Что делать:</strong> укажите ключи одним из двух способов:</p>
|
||
<p class="creds-sub">Способ 1 — переменные окружения:</p>
|
||
<pre>export AWS_ACCESS_KEY_ID=<Access Key>
|
||
export AWS_SECRET_ACCESS_KEY=<Secret Key></pre>
|
||
<p class="creds-sub">Способ 2 — файл ~/.aws/credentials:</p>
|
||
<pre>[sqs]
|
||
aws_access_key_id = <Access Key>
|
||
aws_secret_access_key = <Secret Key></pre>
|
||
<p>Дальше выполняйте команды (для способа 2 добавьте <code>--profile sqs</code>):</p>
|
||
<pre>aws sqs list-queues --endpoint-url https://sqs.containerk8s.dev.nubes.ru --region us-east-1 --profile sqs</pre>
|
||
</div>
|
||
<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>
|
||
|
||
<!-- ===== 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:#ffffff;border:1px solid var(--border);border-radius:8px;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:#ffffff;border:1px solid var(--border);border-radius:8px;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
|
||
|
||
// Session state — JWT auth
|
||
let sessionToken = null; // JWT токен для Authorization header
|
||
let sessionEmail = null; // Email из JWT для отображения в UI
|
||
|
||
// ===== INIT =====
|
||
// Проверяем сохранённый токен в localStorage при загрузке
|
||
(function init() {
|
||
BASE = window.location.origin;
|
||
const saved = localStorage.getItem('sqs_session');
|
||
if (saved) {
|
||
try {
|
||
const s = JSON.parse(saved);
|
||
if (s.token && s.email) {
|
||
sessionToken = s.token;
|
||
sessionEmail = s.email;
|
||
enterApp();
|
||
return;
|
||
}
|
||
} catch(e) { /* corrupted — show login */ }
|
||
}
|
||
// Версия сервиса — берём из публичного /health и показываем на странице входа
|
||
fetch(BASE + '/health')
|
||
.then(r => r.json())
|
||
.then(h => {
|
||
const el = document.getElementById('svc-version');
|
||
if (el && h.version) el.textContent = h.version;
|
||
})
|
||
.catch(() => { /* health недоступен — оставляем '—' */ });
|
||
showLogin();
|
||
})();
|
||
|
||
// ===== LOGIN / LOGOUT =====
|
||
// showLogin — показывает страницу ввода токена
|
||
function showLogin() {
|
||
document.getElementById('login-page').classList.remove('hidden');
|
||
document.getElementById('app').classList.add('hidden');
|
||
if (refreshTimer) clearInterval(refreshTimer);
|
||
}
|
||
|
||
// doLogin — отправляет токен на /ui/api/auth, при успехе входит в app
|
||
function doLogin() {
|
||
const token = document.getElementById('login-token').value.trim();
|
||
if (!token) {
|
||
document.getElementById('login-error').textContent = 'Введите API token или demo token';
|
||
return;
|
||
}
|
||
document.getElementById('login-error').textContent = 'Проверка доступа...';
|
||
fetch(BASE + '/ui/api/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: token })
|
||
})
|
||
.then(r => {
|
||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || r.statusText); });
|
||
return r.json();
|
||
})
|
||
.then(data => {
|
||
sessionToken = data.token;
|
||
sessionEmail = data.email || data.tenant_id;
|
||
localStorage.setItem('sqs_session', JSON.stringify({
|
||
token: sessionToken,
|
||
email: sessionEmail
|
||
}));
|
||
enterApp();
|
||
})
|
||
.catch(err => {
|
||
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
|
||
});
|
||
}
|
||
|
||
// doLogout — очищает сессию, показывает login
|
||
function doLogout() {
|
||
sessionToken = null;
|
||
sessionEmail = null;
|
||
localStorage.removeItem('sqs_session');
|
||
if (refreshTimer) clearInterval(refreshTimer);
|
||
showLogin();
|
||
}
|
||
|
||
// enterApp — переключает UI с login на app shell
|
||
function enterApp() {
|
||
document.getElementById('login-page').classList.add('hidden');
|
||
document.getElementById('app').classList.remove('hidden');
|
||
document.getElementById('user-email').textContent = sessionEmail || '';
|
||
// Юзер сразу попадает в свой аккаунт — без промежуточного дашборда с таблицей.
|
||
api('/tenants')
|
||
.then(list => {
|
||
if (list && list.length > 0) showTenant(list[0].id);
|
||
else showDashboard();
|
||
})
|
||
.catch(() => showDashboard());
|
||
}
|
||
|
||
// ===== API HELPER =====
|
||
// api — выполняет запрос к UI API с JWT Authorization header
|
||
function api(path, opts = {}) {
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
...(opts.headers || {})
|
||
};
|
||
if (sessionToken) {
|
||
headers['Authorization'] = 'Bearer ' + sessionToken;
|
||
}
|
||
return fetch(BASE + '/ui/api' + path, {
|
||
...opts,
|
||
headers: headers
|
||
}).then(r => {
|
||
if (r.status === 401 || r.status === 403) {
|
||
// Токен истёк или невалиден — выбросить на login
|
||
doLogout();
|
||
throw new Error('Session expired — please login again');
|
||
}
|
||
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.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 class="stat-card">
|
||
<div class="stat-value" style="font-size:18px">${health.version || 'dev'}</div>
|
||
<div class="stat-label">Версия</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>
|
||
</div>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<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>${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>
|
||
</tr>
|
||
`).join('')}
|
||
${(!tenants || tenants.length === 0) ? '<tr><td colspan="4" 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="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>
|
||
<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="showCredentials()">Credentials</button>
|
||
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
|
||
</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>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${(queues || []).map(q => `
|
||
<tr>
|
||
<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="7" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей</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');
|
||
}
|
||
|
||
// showCredentials — запрашивает ключи тенанта и показывает их в модалке
|
||
function showCredentials() {
|
||
api('/credentials')
|
||
.then(data => {
|
||
document.getElementById('creds-ak').value = data.access_key || '';
|
||
document.getElementById('creds-sk').value = data.secret_key || '';
|
||
document.getElementById('modal-creds').classList.remove('hidden');
|
||
})
|
||
.catch(err => {
|
||
alert('Не удалось получить credentials: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// 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 + 'с';
|
||
}
|
||
|
||
// 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='var(--brand-grey-light)'" 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_at)}</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_at ? fmtDate(m.sent_at) : '';
|
||
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));
|
||
}
|
||
|
||
// Клавиша Enter на поле токена = нажатие "Войти"
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const tokenInput = document.getElementById('login-token');
|
||
if (tokenInput) {
|
||
tokenInput.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') doLogin();
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|