380 lines
14 KiB
HTML
380 lines
14 KiB
HTML
<!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; }
|
|
.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 METRICS</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn ghost" id="btn-refresh">Refresh</button>
|
|
<a class="btn ghost" href="/console/">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: карточки + таблица.
|
|
</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>History</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>Time</th>
|
|
<th>Source</th>
|
|
<th>Host</th>
|
|
<th>Free mem</th>
|
|
<th>Avail mem</th>
|
|
<th>Used mem</th>
|
|
<th>Mem %</th>
|
|
<th>CPU 1m</th>
|
|
<th>CPU 5m</th>
|
|
<th>CPU 15m</th>
|
|
<th>Disk free</th>
|
|
<th>Uptime</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="history-body">
|
|
<tr><td class="empty" colspan="13">Данных пока нет</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</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 = '/cron/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}d ${hours}h ${mins}m`;
|
|
if (hours > 0) return `${hours}h ${mins}m`;
|
|
return `${Math.floor(sec)}s`;
|
|
}
|
|
|
|
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('Snapshots', '0', 'Waiting for cron POSTs'),
|
|
card('Free memory', '—', 'MB'),
|
|
card('Available memory', '—', 'MB'),
|
|
card('Used memory', '—', 'MB'),
|
|
card('CPU load', '—', '1m / 5m / 15m'),
|
|
card('Disk free', '—', 'GB')
|
|
].join('');
|
|
pointsBadge.textContent = '0 points';
|
|
return;
|
|
}
|
|
|
|
summaryEl.innerHTML = [
|
|
card('Snapshots', String(count), `Source: ${latest.source || 'cron'}`),
|
|
card('Free memory', fmtNum(latest.memory_free_mb, 1, ' MB'), latest.hostname || '—'),
|
|
card('Available memory', fmtNum(latest.memory_available_mb, 1, ' MB'), 'Current available RAM'),
|
|
card('Used memory', fmtNum(latest.memory_used_mb, 1, ' MB'), `Mem ${fmtNum(latest.memory_percent, 1, '%')}`),
|
|
card('CPU load', [fmtNum(latest.cpu_load_1m, 2), fmtNum(latest.cpu_load_5m, 2), fmtNum(latest.cpu_load_15m, 2)].join(' / '), '1m / 5m / 15m'),
|
|
card('Disk free', fmtNum(latest.disk_free_gb, 1, ' GB'), `Uptime ${fmtUptime(latest.uptime_sec)}`)
|
|
].join('');
|
|
pointsBadge.textContent = `${count} point(s)`;
|
|
}
|
|
|
|
function renderTable(history) {
|
|
if (!history.length) {
|
|
historyBody.innerHTML = '<tr><td class="empty" colspan="13">Данных пока нет</td></tr>';
|
|
return;
|
|
}
|
|
|
|
historyBody.innerHTML = history.slice().reverse().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, ' GB')}</td>
|
|
<td class="mono">${fmtUptime(item.uptime_sec)}</td>
|
|
<td class="${cls(item.status)}">${item.status || 'ok'}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
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('Snapshots', '—', 'Load error'),
|
|
card('Free memory', '—', err.message),
|
|
card('Available memory', '—', 'Check backend'),
|
|
card('Used memory', '—', 'Check cron function'),
|
|
card('CPU load', '—', 'Check backend'),
|
|
card('Disk free', '—', 'Check ingest')
|
|
].join('');
|
|
historyBody.innerHTML = `<tr><td class="empty" colspan="13">Ошибка загрузки: ${err.message}</td></tr>`;
|
|
lastUpdatedEl.textContent = 'Ошибка обновления';
|
|
latestSourceEl.textContent = '';
|
|
pointsBadge.textContent = '0 points';
|
|
}
|
|
}
|
|
|
|
document.getElementById('btn-refresh').addEventListener('click', loadMetrics);
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
loadMetrics();
|
|
setInterval(loadMetrics, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|