Files
IoT/internal/service/api/ui/iot-admin.html
T

736 lines
22 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>
<!-- Создано: 2026-04-06
iot-admin.html — страница администратора IoT pipeline.
Показывает: PostgreSQL stats per tenant, очередь shared-SQS, рантайм процесса.
Auth: ADMIN_STATS_TOKEN вводится вручную и хранится в sessionStorage.
Раздаётся по GET /iot-admin (go:embed в бинарнике iot-service).
НЕ для конечных пользователей — только для администратора платформы. -->
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nubes IoT Admin</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<style>
/* Nubes brand palette — те же цвета что в iot-console.html */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120;
color: #e2ecf6;
min-height: 100vh;
}
/* Navbar */
.navbar {
background: #001C34;
border-bottom: 1px solid #0b2d50;
padding: 0 28px;
height: 58px;
display: flex;
align-items: center;
gap: 14px;
}
.navbar-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.navbar-logo img {
height: 18px;
filter: brightness(0) invert(1);
}
.navbar-logo-sep {
width: 1px;
height: 18px;
background: #1a4a73;
margin: 0 4px;
}
.navbar-title {
font-size: 15px;
font-weight: 600;
color: #e2ecf6;
}
.navbar-badge {
background: #2d1a00;
border: 1px solid #7a3a00;
color: #f0a030;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.navbar-spacer {
flex: 1;
}
.navbar-refresh {
background: #0f3a60;
border: 1px solid #1a5a8a;
color: #7fc8f8;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.navbar-refresh:hover {
background: #1a5080;
}
.navbar-refresh:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Layout */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 28px 24px;
}
/* Auth box */
.auth-box {
background: #001929;
border: 1px solid #0b2d50;
border-radius: 12px;
padding: 40px;
max-width: 480px;
margin: 80px auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.auth-box h2 {
font-size: 20px;
font-weight: 600;
color: #7fc8f8;
}
.auth-box p {
font-size: 13px;
color: #6b8eaa;
}
.auth-input {
background: #001120;
border: 1px solid #1a4a73;
color: #e2ecf6;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
font-family: monospace;
width: 100%;
outline: none;
}
.auth-input:focus {
border-color: #1a7fd4;
}
.auth-btn {
background: #1a7fd4;
border: none;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
font-weight: 600;
transition: background 0.15s;
}
.auth-btn:hover {
background: #1a6ab8;
}
.auth-error {
color: #f87171;
font-size: 13px;
}
/* Section header */
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #0b2d50;
}
.section-icon {
width: 20px;
height: 20px;
opacity: 0.7;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #a0c4e8;
}
.section {
margin-bottom: 32px;
}
/* Cards grid */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
/* Stat card */
.card {
background: #001929;
border: 1px solid #0b2d50;
border-radius: 10px;
padding: 20px;
}
.card-title {
font-size: 12px;
color: #6b8eaa;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.card-value {
font-size: 28px;
font-weight: 700;
color: #e2ecf6;
}
.card-sub {
font-size: 12px;
color: #6b8eaa;
margin-top: 4px;
}
.card-accent {
color: #1a7fd4;
}
.card-warn {
color: #f59e0b;
}
.card-ok {
color: #34d399;
}
.card-err {
color: #f87171;
}
/* Tenant table */
.tenant-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tenant-table th {
text-align: left;
padding: 8px 12px;
color: #6b8eaa;
border-bottom: 1px solid #0b2d50;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tenant-table td {
padding: 10px 12px;
border-bottom: 1px solid #071a28;
vertical-align: top;
}
.tenant-table tr:last-child td {
border-bottom: none;
}
.tenant-table tr:hover td {
background: rgba(26, 127, 212, 0.05);
}
.ns-tag {
font-family: monospace;
font-size: 12px;
color: #7fc8f8;
background: #0b2d50;
padding: 2px 6px;
border-radius: 4px;
}
.num-big {
font-size: 16px;
font-weight: 600;
color: #e2ecf6;
}
.num-small {
font-size: 12px;
color: #6b8eaa;
}
/* Latest msgs mini list */
.latest-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.latest-item {
background: #001120;
border: 1px solid #0b2d50;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
}
.latest-dev {
color: #7fc8f8;
font-weight: 600;
}
.latest-ts {
color: #6b8eaa;
margin-left: 6px;
}
.latest-payload {
color: #a0c4e8;
margin-top: 2px;
word-break: break-all;
font-family: monospace;
}
/* Last updated */
.last-updated {
font-size: 12px;
color: #2d5070;
text-align: center;
margin-top: 16px;
}
/* Spinner */
.spinner {
border: 3px solid #0b2d50;
border-top-color: #1a7fd4;
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 0.8s linear infinite;
margin: 60px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error banner */
.error-banner {
background: #1a0000;
border: 1px solid #5a0000;
color: #f87171;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
}
/* Auto-refresh indicator */
.refresh-timer {
font-size: 11px;
color: #2d5070;
display: flex;
align-items: center;
gap: 6px;
}
.refresh-progress {
width: 60px;
height: 2px;
background: #0b2d50;
border-radius: 2px;
overflow: hidden;
}
.refresh-bar {
height: 100%;
background: #1a7fd4;
border-radius: 2px;
transition: width 1s linear;
}
</style>
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<a class="navbar-logo" href="#" aria-label="Nubes">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
</a>
<div class="navbar-logo-sep"></div>
<span class="navbar-title">IoT Admin</span>
<span class="navbar-badge">Admin Only</span>
<div class="navbar-spacer"></div>
<div class="refresh-timer" id="refreshTimer" style="display:none">
<span id="refreshCountdown">30</span>s
<div class="refresh-progress">
<div class="refresh-bar" id="refreshBar" style="width:100%"></div>
</div>
</div>
<button class="navbar-refresh" id="btnRefresh" onclick="loadStats()" disabled>Обновить</button>
</nav>
<!-- Main content -->
<div class="container">
<!-- Auth box (показывается до ввода токена) -->
<div class="auth-box" id="authBox">
<h2>Доступ для администратора</h2>
<p>Введите ADMIN_STATS_TOKEN для просмотра статистики IoT pipeline.</p>
<input class="auth-input" id="tokenInput" type="password" placeholder="Bearer token..." autocomplete="off"
onkeydown="if(event.key==='Enter') doAuth()">
<button class="auth-btn" onclick="doAuth()">Войти</button>
<div class="auth-error" id="authError" style="display:none"></div>
</div>
<!-- Контент (показывается после авторизации) -->
<div id="mainContent" style="display:none">
<div class="error-banner" id="errorBanner" style="display:none"></div>
<!-- Spinner при загрузке -->
<div class="spinner" id="spinner"></div>
<!-- Данные -->
<div id="dataContent" style="display:none">
<!-- shared-SQS: очередь телеметрии -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span class="section-title">Очередь shared-SQS</span>
</div>
<div class="cards" id="sqsCards"></div>
</div>
<!-- Рантайм процесса -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
<span class="section-title">Рантайм</span>
</div>
<div class="cards" id="runtimeCards"></div>
</div>
<!-- PostgreSQL per tenant -->
<div class="section">
<div class="section-header">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="#7fc8f8" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
</svg>
<span class="section-title">PostgreSQL — Telemetry</span>
</div>
<div class="cards" style="margin-bottom:16px" id="pgSummaryCards"></div>
<div style="background:#001929;border:1px solid #0b2d50;border-radius:10px;overflow:auto">
<table class="tenant-table" id="tenantTable">
<thead>
<tr>
<th>Namespace</th>
<th>Total</th>
<th>Last 1h</th>
<th>Last 24h</th>
<th>Последние сообщения</th>
</tr>
</thead>
<tbody id="tenantTableBody"></tbody>
</table>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
</div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const API_BASE = window.location.origin;
let adminToken = sessionStorage.getItem('iot_admin_token') || '';
let refreshInterval = null;
let refreshCountdown = 30;
// ── Auth ───────────────────────────────────────────────────────────────────
function doAuth() {
const input = document.getElementById('tokenInput').value.trim();
if (!input) return;
adminToken = input;
sessionStorage.setItem('iot_admin_token', adminToken);
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
loadStats();
}
function showAuthError(msg) {
const el = document.getElementById('authError');
el.textContent = msg;
el.style.display = 'block';
// Сбрасываем токен — он не подошёл
adminToken = '';
sessionStorage.removeItem('iot_admin_token');
document.getElementById('authBox').style.display = 'block';
document.getElementById('mainContent').style.display = 'none';
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
document.getElementById('refreshTimer').style.display = 'none';
}
// Если токен уже в sessionStorage — пропускаем auth box
if (adminToken) {
document.getElementById('authBox').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
document.getElementById('spinner').style.display = 'block';
}
// ── Load stats ─────────────────────────────────────────────────────────────
async function loadStats() {
if (!adminToken) return;
document.getElementById('btnRefresh').disabled = true;
document.getElementById('spinner').style.display = 'block';
document.getElementById('dataContent').style.display = 'none';
document.getElementById('errorBanner').style.display = 'none';
resetRefreshTimer();
try {
const resp = await fetch(`${API_BASE}/iot-admin/stats`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (resp.status === 401 || resp.status === 503) {
const body = await resp.json().catch(() => ({}));
showAuthError(body.error || 'Ошибка авторизации');
document.getElementById('spinner').style.display = 'none';
return;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
renderAll(data);
document.getElementById('spinner').style.display = 'none';
document.getElementById('dataContent').style.display = 'block';
document.getElementById('refreshTimer').style.display = 'flex';
document.getElementById('lastUpdated').textContent =
'Обновлено: ' + new Date(data.collected_at).toLocaleTimeString('ru-RU');
setupAutoRefresh();
} catch (e) {
document.getElementById('spinner').style.display = 'none';
showBanner('Ошибка загрузки данных: ' + e.message);
document.getElementById('dataContent').style.display = 'block';
} finally {
document.getElementById('btnRefresh').disabled = false;
}
}
function showBanner(msg) {
const el = document.getElementById('errorBanner');
el.textContent = msg;
el.style.display = 'block';
}
// ── Auto-refresh ───────────────────────────────────────────────────────────
function setupAutoRefresh() {
if (refreshInterval) return; // уже запущен
refreshInterval = setInterval(() => {
refreshCountdown--;
document.getElementById('refreshCountdown').textContent = refreshCountdown;
const pct = (refreshCountdown / 30) * 100;
document.getElementById('refreshBar').style.width = pct + '%';
if (refreshCountdown <= 0) {
clearInterval(refreshInterval);
refreshInterval = null;
loadStats();
}
}, 1000);
}
function resetRefreshTimer() {
if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; }
refreshCountdown = 30;
document.getElementById('refreshCountdown').textContent = '30';
document.getElementById('refreshBar').style.width = '100%';
}
// ── Render ─────────────────────────────────────────────────────────────────
function renderAll(data) {
renderSQS(data.sqs || {});
renderRuntime(data.runtime || {});
renderPostgres(data.postgres || {});
}
// Очередь shared-SQS
function renderSQS(sqs) {
const el = document.getElementById('sqsCards');
if (sqs.error) {
el.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(sqs.error)}</div></div>`;
return;
}
const pending = sqs.approximate_messages || 0;
const inFlight = sqs.approximate_messages_not_visible || 0;
const pendingClass = pending === 0 ? 'card-ok' : pending < 100 ? 'card-warn' : 'card-err';
el.innerHTML = `
<div class="card">
<div class="card-title">Ожидают обработки</div>
<div class="card-value ${pendingClass}">${pending.toLocaleString()}</div>
<div class="card-sub">ApproximateNumberOfMessages</div>
</div>
<div class="card">
<div class="card-title">В обработке</div>
<div class="card-value card-accent">${inFlight.toLocaleString()}</div>
<div class="card-sub">ApproximateNumberOfMessagesNotVisible</div>
</div>`;
}
// Рантайм процесса
function renderRuntime(rt) {
const el = document.getElementById('runtimeCards');
const uptime = rt.uptime_sec || 0;
const h = Math.floor(uptime / 3600);
const m = Math.floor((uptime % 3600) / 60);
const s = uptime % 60;
const uptimeStr = h > 0 ? `${h}ч ${m}м` : m > 0 ? `${m}м ${s}с` : `${s}с`;
el.innerHTML = `
<div class="card">
<div class="card-title">Версия</div>
<div class="card-value" style="font-size:20px">${esc(rt.version || '—')}</div>
<div class="card-sub">iot-service</div>
</div>
<div class="card">
<div class="card-title">Uptime</div>
<div class="card-value card-accent">${uptimeStr}</div>
<div class="card-sub">с ${esc(rt.started_at || '—')}</div>
</div>`;
}
// Postgres section
function renderPostgres(pg) {
const summaryEl = document.getElementById('pgSummaryCards');
const tbodyEl = document.getElementById('tenantTableBody');
if (!pg.reachable) {
summaryEl.innerHTML = `<div class="card"><div class="card-title">Ошибка</div>
<div class="card-value card-err" style="font-size:14px">${esc(pg.error || 'Unreachable')}</div></div>`;
tbodyEl.innerHTML = '';
return;
}
const tenants = pg.tenants || [];
const totalAll = pg.total_all || 0;
summaryEl.innerHTML = `
<div class="card">
<div class="card-title">Всего записей</div>
<div class="card-value card-accent">${totalAll.toLocaleString()}</div>
<div class="card-sub">Все tenant, iot_telemetry</div>
</div>
<div class="card">
<div class="card-title">Tenant-ов</div>
<div class="card-value">${tenants.length}</div>
<div class="card-sub">Активных namespace</div>
</div>`;
tbodyEl.innerHTML = tenants.map(t => {
if (t.error) {
return `<tr><td><span class="ns-tag">${esc(t.namespace)}</span></td>
<td colspan="4" style="color:#f87171">${esc(t.error)}</td></tr>`;
}
const latest = (t.latest || []).slice(0, 3);
const latestHtml = latest.length === 0
? '<span style="color:#2d5070">нет данных</span>'
: `<div class="latest-list">${latest.map(row => `
<div class="latest-item">
<span class="latest-dev">${esc(row.device_id)}</span>
<span class="latest-ts">${formatTs(row.ts)}</span>
<div class="latest-payload">${esc(truncate(JSON.stringify(row.payload), 80))}</div>
</div>`).join('')}</div>`;
return `<tr>
<td><span class="ns-tag">${esc(t.namespace)}</span><br>
<span style="font-size:11px;color:#2d5070">${esc(t.db_name)}</span></td>
<td><span class="num-big">${(t.total || 0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_1h || 0).toLocaleString()}</span></td>
<td><span class="num-small">${(t.last_24h || 0).toLocaleString()}</span></td>
<td>${latestHtml}</td>
</tr>`;
}).join('');
}
// ── Utils ──────────────────────────────────────────────────────────────────
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
function formatTs(ts) {
if (!ts) return '';
try {
const d = new Date(ts);
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return ts; }
}
// ── Init ───────────────────────────────────────────────────────────────────
if (adminToken) {
loadStats();
}
</script>
</body>
</html>