Files

410 lines
13 KiB
HTML

<!DOCTYPE html>
<!-- app/ui/admin.html — админский UI shared-sqs (тенанты, лимиты). Только по admin-токену. -->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SQS Admin — Nubes</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.5;
min-height: 100vh;
}
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px 48px; }
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header { background: var(--brand-grey-light); padding: 12px; font-weight: 600; }
.card-body { padding: 16px; }
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar h2 { font-size: 20px; }
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 8px 10px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 8px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
code, .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
.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); }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--brand-gray);
background: #fff;
color: var(--text-primary);
}
.btn:hover { background: var(--brand-grey-light); }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn-danger { background: #fff; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: rgba(220,38,38,0.08); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.form-group input {
width: 100%;
padding: 9px 12px;
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
}
.form-group input:focus { outline: none; border-color: var(--brand-primary); }
.error { color: var(--danger); font-size: 13px; min-height: 20px; margin: 8px 0; }
.login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 32px;
width: 400px;
max-width: 92vw;
}
.login-box img { height: 28px; display: block; margin: 0 auto 12px; }
.login-box h1 { font-size: 20px; text-align: center; margin-bottom: 24px; }
.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: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
padding: 28px;
width: 480px;
max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
}
.modal h2 { font-size: 20px; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
.hidden { display: none !important; }
.hint {
font-size: 13px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
line-height: 1.6;
}
.hint .warn { color: var(--warning); font-weight: 600; }
.empty { text-align: center; color: var(--text-muted); padding: 28px; }
</style>
</head>
<body>
<div id="login-page" class="login-page">
<div class="login-box">
<img src="/static/logo.svg" alt="Nubes">
<h1>SQS Admin</h1>
<div class="form-group">
<label>Admin-токен</label>
<input id="admin-token" type="password" placeholder="Bearer admin token">
</div>
<div id="login-error" class="error"></div>
<button class="btn btn-primary" style="width:100%;justify-content:center" onclick="doLogin()">Войти</button>
</div>
</div>
<div id="app" class="hidden">
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">SQS Admin</span>
<span class="muted">· тенанты</span>
<span class="spacer"></span>
<span class="chip">{{VERSION}}</span>
<button class="btn btn-sm" onclick="doLogout()">Выйти</button>
</div>
<div class="container">
<div class="card">
<div class="toolbar" style="padding:14px 16px 0">
<h2>Тенанты</h2>
<div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-sm" onclick="loadTenants()">⟳ Обновить</button>
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">+ Создать</button>
</div>
</div>
<div class="card-body">
<div id="list-error" class="error"></div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>ID</th>
<th>Access Key</th>
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tenants-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeCreateModal()">
<div class="modal">
<h2>Создать тенанта</h2>
<div class="form-group">
<label>Имя</label>
<input id="ct-name" placeholder="service-name">
</div>
<div class="form-group">
<label>Макс. очередей</label>
<input id="ct-queues" type="number" value="10" min="1" max="1000">
</div>
<div id="ct-error" class="error"></div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="createTenant()">Создать</button>
<button class="btn" onclick="closeCreateModal()">Отмена</button>
</div>
</div>
</div>
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal">
<h2>Тенант создан</h2>
<div class="hint">
<p class="warn">⚠ Передайте эти ключи владельцу тенанта.</p>
<p>Это креды для AWS CLI/SDK (подпись Signature V4).</p>
</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>
<script>
let adminToken = localStorage.getItem('sqs_admin_token') || '';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
function api(path, opts = {}) {
return fetch(path, {
...opts,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + adminToken,
...(opts.headers || {})
}
}).then(r => {
if (r.status === 401) {
doLogout();
throw new Error('Неверный или истёкший admin-токен');
}
if (!r.ok) {
return r.json().catch(() => ({}))
.then(d => { throw new Error(d.error || r.status + ' ' + r.statusText); });
}
if (r.status === 204) return null;
return r.json();
});
}
function doLogin() {
const t = document.getElementById('admin-token').value.trim();
if (!t) return;
adminToken = t;
api('/admin/tenants')
.then(() => {
localStorage.setItem('sqs_admin_token', t);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
loadTenants();
})
.catch(err => {
adminToken = '';
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
});
}
function doLogout() {
adminToken = '';
localStorage.removeItem('sqs_admin_token');
document.getElementById('app').classList.add('hidden');
document.getElementById('login-page').classList.remove('hidden');
}
function loadTenants() {
document.getElementById('list-error').textContent = '';
api('/admin/tenants')
.then(list => renderTenants(list || []))
.catch(err => { document.getElementById('list-error').textContent = 'Ошибка: ' + err.message; });
}
function renderTenants(list) {
const tbody = document.getElementById('tenants-tbody');
tbody.innerHTML = (list || []).map(t => `
<tr>
<td><strong>${esc(t.name)}</strong>${t.id === (window._lastCreated || '') ? '' : ''}</td>
<td class="mono">${esc(t.id)}</td>
<td class="mono">${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>${fmtDate(t.created_at)}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteTenant('${esc(t.id)}','${esc(t.name)}')">Удалить</button></td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Тенантов нет</td></tr>';
}
function openCreateModal() {
document.getElementById('ct-name').value = '';
document.getElementById('ct-queues').value = '10';
document.getElementById('ct-error').textContent = '';
document.getElementById('modal-create').classList.remove('hidden');
}
function closeCreateModal() {
document.getElementById('modal-create').classList.add('hidden');
}
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 => {
closeCreateModal();
document.getElementById('creds-ak').value = data.access_key || '';
document.getElementById('creds-sk').value = data.secret_key || '';
document.getElementById('modal-creds').classList.remove('hidden');
loadTenants();
})
.catch(err => { document.getElementById('ct-error').textContent = 'Ошибка: ' + err.message; });
}
function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden');
}
function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди и сообщения будут удалены.')) return;
api('/admin/tenants/' + id, { method: 'DELETE' })
.then(() => loadTenants())
.catch(err => alert('Ошибка: ' + err.message));
}
function copyField(el) {
el.select();
navigator.clipboard && navigator.clipboard.writeText(el.value);
}
// init
if (adminToken) {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
loadTenants();
}
</script>
</body>
</html>