410 lines
13 KiB
HTML
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
.replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
|
|
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>
|