Files
fission-console/metrics-collector/ui/index.html
T

431 lines
17 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>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NUBES Cron</title>
<style>
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001c34;
--border: #0b2d50;
--accent: #1a7fd4;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
--good: #56d364;
--warn: #e3b341;
--bad: #f85149;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: linear-gradient(180deg, #001120 0%, #061424 100%);
color: var(--text-primary);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.navbar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--bg-navbar);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff; display: grid; place-items: center;
font-size: 14px; font-weight: 800; letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.08; }
.brand-text .nubes { font-size: 12px; letter-spacing: .22em; font-weight: 800; color: #8bc7ff; }
.brand-text .product { font-size: 13px; font-weight: 700; letter-spacing: .04em; }
.btn {
background: var(--accent); color: #fff; border: 0; border-radius: 6px;
padding: 8px 12px; cursor: pointer; font-size: 13px; line-height: 1.2;
}
.btn.ghost { background: #0c2b49; }
.btn:hover { opacity: .95; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 20px; }
.hero {
background: linear-gradient(180deg, #001929 0%, #001622 100%);
border: 1px solid var(--border); border-radius: 14px; padding: 18px;
box-shadow: 0 12px 34px rgba(0, 0, 0, .18);
}
.hero-top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.hero h1 { font-size: 28px; margin-bottom: 6px; }
.hero p { color: var(--text-secondary); max-width: 920px; line-height: 1.5; font-size: 14px; }
.hero .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.clock {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(12, 43, 73, .72);
border: 1px solid #1d486d;
color: #d7e7f7;
font-size: 14px;
font-weight: 700;
letter-spacing: .02em;
}
.clock small {
display: block;
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 2px;
}
.grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;
margin-top: 16px;
}
.card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px;
}
.k { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.v { font-size: 24px; font-weight: 800; margin-top: 6px; }
.s { font-size: 12px; color: var(--text-secondary); margin-top: 4px; min-height: 16px; }
.panel {
margin-top: 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.panel-head {
display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.panel-head h2 { font-size: 16px; }
.meta { color: var(--text-secondary); font-size: 12px; }
table { width: 100%; border-collapse: collapse; min-width: 1220px; }
th, td { padding: 9px 11px; border-bottom: 1px solid #0b2a48; text-align: left; }
th {
color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
}
td { font-size: 13px; vertical-align: middle; }
tbody tr:hover { background: rgba(26, 127, 212, .08); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.status-ok { color: var(--good); }
.status-warn { color: var(--warn); }
.status-bad { color: var(--bad); }
.empty { color: var(--text-secondary); padding: 24px 12px; text-align: center; }
.pagination {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 12px 16px; border-top: 1px solid var(--border);
}
.page-btn {
min-width: 32px; height: 28px; padding: 0 8px; border-radius: 6px;
background: #0c2b49; border: 1px solid #1d486d; color: #d7e7f7;
font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.page-btn:hover { background: #153d68; }
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.page-btn:disabled { opacity: .35; cursor: default; }
.page-info { color: var(--text-secondary); font-size: 12px; margin-left: 6px; }
.footer {
padding-top: 12px; color: var(--text-secondary); font-size: 12px; line-height: 1.5;
}
.badge {
display: inline-block; padding: 2px 7px; border-radius: 999px; background: #0c2b49; border: 1px solid #1d486d;
color: #d7e7f7; font-size: 11px;
}
@media (max-width: 820px) {
.hero h1 { font-size: 22px; }
}
</style>
</head>
<body>
<div class="navbar">
<div class="brand">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">МЕТРИКИ CRON</div>
</div>
</div>
<div class="actions">
<button class="btn ghost" id="btn-refresh">Обновить</button>
<a class="btn ghost" href="/console/">Консоль</a>
</div>
</div>
<div class="wrap">
<section class="hero">
<div class="hero-top">
<div>
<h1>Снимки состояния по cron</h1>
<p>
Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime.
Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица.
<br><br>
<strong>Примечание:</strong> метрики читаются из <span class="mono">/proc</span> внутри pod-а и отражают состояние
<em>ноды кластера</em>, на которой запущена функция — не ресурсы самого pod-а и не агрегированные показатели кластера.
</p>
<div class="clock">
<div>
<small>Current time</small>
<span id="current-time">--:--:--</span>
</div>
</div>
</div>
<div class="actions">
<span class="badge" id="points-badge">0 points</span>
</div>
</div>
<div class="grid" id="summary"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>История</h2>
<div class="meta" id="last-updated">Ожидание первых данных…</div>
</div>
<div class="meta mono" id="latest-source"></div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Время</th>
<th>Источник</th>
<th>Хост</th>
<th>Своб. МБ</th>
<th>Дост. МБ</th>
<th>Исп. МБ</th>
<th>Память %</th>
<th>ЦП 1м</th>
<th>ЦП 5м</th>
<th>ЦП 15м</th>
<th>Диск своб.</th>
<th>Аптайм</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td class="empty" colspan="13">Данных пока нет</td></tr>
</tbody>
</table>
</div>
<div id="pagination" class="pagination"></div>
</section>
<div class="footer">
Endpoint для cron-функции: <span class="mono">POST /cron/api/metrics</span>.<br>
Пример полезной нагрузки: <span class="mono">{"source":"my-fn","memory_free_mb":1234,"cpu_load_1m":0.12}</span>.
</div>
</div>
<script>
const API = '/metrics';
const summaryEl = document.getElementById('summary');
const historyBody = document.getElementById('history-body');
const lastUpdatedEl = document.getElementById('last-updated');
const latestSourceEl = document.getElementById('latest-source');
const pointsBadge = document.getElementById('points-badge');
const currentTimeEl = document.getElementById('current-time');
function num(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function fmtNum(v, d = 1, suffix = '') {
const n = num(v);
if (n === null) return '—';
return n.toFixed(d) + suffix;
}
function fmtTime(v) {
if (!v) return '—';
const d = new Date(v);
if (Number.isNaN(d.getTime())) return String(v);
return new Intl.DateTimeFormat('ru-RU', {
year: '2-digit', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(d);
}
function updateClock() {
currentTimeEl.textContent = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(new Date());
}
function fmtUptime(v) {
const sec = num(v);
if (sec === null) return '—';
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
const mins = Math.floor((sec % 3600) / 60);
if (days > 0) return `${days}д ${hours}ч ${mins}м`;
if (hours > 0) return `${hours}ч ${mins}м`;
return `${Math.floor(sec)}с`;
}
function cls(status) {
const s = String(status || '').toLowerCase();
if (s.includes('err') || s.includes('fail') || s.includes('bad')) return 'status-bad';
if (s.includes('warn') || s.includes('degrad') || s.includes('idle')) return 'status-warn';
return 'status-ok';
}
function card(label, value, subtitle) {
return `
<div class="card">
<div class="k">${label}</div>
<div class="v">${value}</div>
<div class="s">${subtitle || ''}</div>
</div>
`;
}
function renderSummary(latest, count) {
if (!latest) {
summaryEl.innerHTML = [
card('Снимки', '0', 'Ожидание cron-запросов'),
card('Своб. память', '—', 'МБ'),
card('Дост. память', '—', 'МБ'),
card('Исп. память', '—', 'МБ'),
card('Нагрузка ЦП', '—', '1м / 5м / 15м'),
card('Диск своб.', '—', 'ГБ')
].join('');
pointsBadge.textContent = '0 снимков';
return;
}
summaryEl.innerHTML = [
card('Снимки', String(count), `Источник: ${latest.source || 'cron'}`),
card('Своб. память', fmtNum(latest.memory_free_mb, 1, ' МБ'), latest.hostname || '—'),
card('Дост. память', fmtNum(latest.memory_available_mb, 1, ' МБ'), 'Доступная RAM'),
card('Исп. память', fmtNum(latest.memory_used_mb, 1, ' МБ'), `Память ${fmtNum(latest.memory_percent, 1, '%')}`),
card('Нагрузка ЦП', [fmtNum(latest.cpu_load_1m, 2), fmtNum(latest.cpu_load_5m, 2), fmtNum(latest.cpu_load_15m, 2)].join(' / '), '1м / 5м / 15м'),
card('Диск своб.', fmtNum(latest.disk_free_gb, 1, ' ГБ'), `Аптайм ${fmtUptime(latest.uptime_sec)}`)
].join('');
pointsBadge.textContent = `${count} снимк${count === 1 ? '' : 'ов'}`;
}
const PAGE_SIZE = 10;
let allHistory = [];
let currentPage = 1;
function renderPage(page) {
const total = allHistory.length;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), pages);
const pg = document.getElementById('pagination');
if (!total) {
historyBody.innerHTML = '<tr><td class="empty" colspan="13">Данных пока нет</td></tr>';
pg.innerHTML = '';
return;
}
const start = (currentPage - 1) * PAGE_SIZE;
const slice = allHistory.slice(start, start + PAGE_SIZE);
historyBody.innerHTML = slice.map(function (item) {
return `
<tr>
<td class="mono">${fmtTime(item.timestamp)}</td>
<td>${item.source || '—'}</td>
<td>${item.hostname || '—'}</td>
<td class="mono">${fmtNum(item.memory_free_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_available_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_used_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_percent, 1, '%')}</td>
<td class="mono">${fmtNum(item.cpu_load_1m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_5m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_15m, 2)}</td>
<td class="mono">${fmtNum(item.disk_free_gb, 1, ' ГБ')}</td>
<td class="mono">${fmtUptime(item.uptime_sec)}</td>
<td class="${cls(item.status)}">${item.status || 'ok'}</td>
</tr>
`;
}).join('');
if (pages <= 1) { pg.innerHTML = ''; return; }
let btns = `<button class="page-btn" id="pg-prev" ${currentPage === 1 ? 'disabled' : ''}>←</button>`;
for (let i = 1; i <= pages; i++) {
btns += `<button class="page-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
btns += `<button class="page-btn" id="pg-next" ${currentPage === pages ? 'disabled' : ''}>→</button>`;
btns += `<span class="page-info">стр. ${currentPage} из ${pages} · всего ${total}</span>`;
pg.innerHTML = btns;
pg.querySelector('#pg-prev').addEventListener('click', function () { renderPage(currentPage - 1); });
pg.querySelector('#pg-next').addEventListener('click', function () { renderPage(currentPage + 1); });
pg.querySelectorAll('[data-page]').forEach(function (btn) {
btn.addEventListener('click', function () { renderPage(Number(btn.dataset.page)); });
});
}
function renderTable(history) {
allHistory = history.slice().reverse();
renderPage(1);
}
function updateMeta(history) {
if (!history.length) {
lastUpdatedEl.textContent = 'Ожидание первых данных…';
latestSourceEl.textContent = '';
return;
}
const latest = history[history.length - 1];
lastUpdatedEl.textContent = `Последний снимок: ${fmtTime(latest.timestamp)}${latest.hostname ? ' · ' + latest.hostname : ''}`;
latestSourceEl.textContent = latest.source ? `source=${latest.source}` : '';
}
async function loadMetrics() {
try {
const res = await fetch(API, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const history = Array.isArray(data.history) ? data.history : [];
const latest = data.latest || (history.length ? history[history.length - 1] : null);
renderSummary(latest, history.length || (latest ? 1 : 0));
renderTable(history);
updateMeta(history);
} catch (err) {
summaryEl.innerHTML = [
card('Снимки', '—', 'Ошибка загрузки'),
card('Своб. память', '—', err.message),
card('Дост. память', '—', 'Проверить backend'),
card('Исп. память', '—', 'Проверить cron-функцию'),
card('Нагрузка ЦП', '—', 'Проверить backend'),
card('Диск своб.', '—', 'Проверить приём данных')
].join('');
historyBody.innerHTML = `<tr><td class="empty" colspan="13">Ошибка загрузки: ${err.message}</td></tr>`;
lastUpdatedEl.textContent = 'Ошибка обновления';
latestSourceEl.textContent = '';
pointsBadge.textContent = '0 снимков';
}
}
document.getElementById('btn-refresh').addEventListener('click', loadMetrics);
updateClock();
setInterval(updateClock, 1000);
loadMetrics();
setInterval(loadMetrics, 30000);
</script>
</body>
</html>