- 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
674 lines
19 KiB
HTML
674 lines
19 KiB
HTML
<!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>
|