Files
ngx_http_monitoring_module/assets/dashboard.html
2026-05-07 23:24:46 +03:30

393 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nginx Monitor</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230b0f14'/%3E%3Ccircle cx='32' cy='32' r='24' fill='%23111821' stroke='%23263344' stroke-width='3'/%3E%3Cpath d='M14 36h9l5-18 8 30 5-16h9' fill='none' stroke='%2345d4d0' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M17 20h30' fill='none' stroke='%2345d483' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E">
<style>
:root {
color-scheme: dark;
--bg: #0b0f14;
--panel: #111821;
--panel-2: #151f2b;
--line: #263344;
--text: #e6edf3;
--muted: #8fa1b3;
--green: #45d483;
--red: #ff5f6d;
--yellow: #f4c95d;
--blue: #5aa9ff;
--cyan: #45d4d0;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, Segoe UI, sans-serif;
}
header {
height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: #0f151d;
position: sticky;
top: 0;
z-index: 5;
}
.brand { font-size: 17px; font-weight: 700; }
.sub, .small, .label { color: var(--muted); }
.sub, .small, .label { font-size: 12px; }
.status { display: flex; align-items: center; gap: 8px; color: var(--muted); }
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 3px rgba(255, 95, 109, .12);
}
.dot.ok {
background: var(--green);
box-shadow: 0 0 0 3px rgba(69, 212, 131, .13);
}
.layout { display: grid; grid-template-columns: 190px 1fr; min-height: calc(100vh - 58px); }
nav {
border-right: 1px solid var(--line);
padding: 14px;
background: #0d131a;
position: sticky;
top: 58px;
height: calc(100vh - 58px);
}
button.tab {
width: 100%;
height: 36px;
border: 0;
background: transparent;
color: var(--muted);
text-align: left;
padding: 0 12px;
border-radius: 7px;
cursor: pointer;
font-weight: 600;
}
button.tab.active, button.tab:hover { background: var(--panel-2); color: var(--text); }
main { padding: 18px; max-width: 1500px; width: 100%; margin: 0 auto; }
.grid { display: grid; gap: 12px; }
.cards { grid-template-columns: repeat(6, minmax(120px, 1fr)); }
.card, .panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.card { padding: 12px; min-height: 86px; }
.value { font-size: 24px; font-weight: 750; margin-top: 8px; letter-spacing: 0; }
.charts { grid-template-columns: repeat(2, minmax(280px, 1fr)); margin-top: 12px; }
.panel { padding: 14px; min-width: 0; }
.panel h2 { font-size: 14px; margin: 0 0 10px; }
.chart { width: 100%; height: 210px; display: block; }
.toolbar { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; }
input, select {
height: 32px;
background: #0d131a;
border: 1px solid var(--line);
border-radius: 7px;
color: var(--text);
padding: 0 10px;
min-width: 0;
}
input { width: 260px; max-width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 8px 9px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { color: var(--muted); font-weight: 650; cursor: pointer; }
td.truncate { max-width: 420px; overflow: hidden; text-overflow: ellipsis; }
.page { display: none; }
.page.active { display: block; }
.split { grid-template-columns: repeat(2, minmax(280px, 1fr)); }
details { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 10px; }
summary { cursor: pointer; color: var(--muted); }
@media (max-width: 980px) {
.layout { grid-template-columns: 1fr; }
nav {
position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.cards { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
.charts, .split { grid-template-columns: 1fr; }
header { padding: 0 12px; }
main { padding: 12px; }
td.truncate { max-width: 220px; }
}
</style>
</head>
<body>
<header>
<div>
<div class="brand">Nginx Monitor</div>
<div class="sub" id="stamp">waiting</div>
</div>
<div class="status"><span class="dot" id="dot"></span><span id="conn">offline</span></div>
</header>
<div class="layout">
<nav id="nav"></nav>
<main>
<section class="page active" data-page="Overview">
<div class="grid cards" id="cards"></div>
<div class="grid charts">
<div class="panel"><h2>CPU</h2><canvas class="chart" id="cpuChart"></canvas></div>
<div class="panel"><h2>Requests</h2><canvas class="chart" id="rpsChart"></canvas></div>
<div class="panel"><h2>Network</h2><canvas class="chart" id="netChart"></canvas></div>
<div class="panel"><h2>Latency</h2><canvas class="chart" id="latChart"></canvas></div>
</div>
</section>
<section class="page" data-page="CPU">
<div class="grid split">
<div class="panel"><h2>CPU Load</h2><canvas class="chart" id="cpuChart2"></canvas></div>
<div class="panel"><h2>Load Average</h2><table id="loadTable"></table></div>
</div>
</section>
<section class="page" data-page="Memory">
<div class="grid split">
<div class="panel"><h2>Memory</h2><canvas class="chart" id="memChart"></canvas></div>
<div class="panel"><h2>Memory Stats</h2><table id="memTable"></table></div>
</div>
</section>
<section class="page" data-page="Network">
<div class="panel">
<div class="toolbar"><input id="ifFilter" placeholder="Filter"><select id="ifSort"><option value="rx_bytes">RX</option><option value="tx_bytes">TX</option><option value="name">Name</option></select></div>
<table id="ifTable"></table>
</div>
</section>
<section class="page" data-page="Disk">
<div class="grid split">
<div class="panel"><h2>Devices</h2><table id="diskTable"></table></div>
<div class="panel"><h2>Filesystems</h2><table id="fsTable"></table></div>
</div>
</section>
<section class="page" data-page="Requests">
<div class="panel">
<div class="toolbar"><input id="urlFilter" placeholder="Filter"><select id="urlSort"><option value="hits">Hits</option><option value="errors">Errors</option><option value="avg_latency">Latency</option></select></div>
<table id="urlTable"></table>
<details><summary>User agents</summary><table id="uaTable"></table></details>
</div>
</section>
<section class="page" data-page="Upstreams"><div class="panel"><table id="upTable"></table></div></section>
<section class="page" data-page="Workers"><div class="panel"><table id="workerTable"></table></div></section>
<section class="page" data-page="Errors">
<div class="grid split">
<div class="panel"><h2>Status</h2><table id="errTable"></table></div>
<div class="panel"><h2>Error Rate</h2><canvas class="chart" id="errChart"></canvas></div>
</div>
</section>
</main>
</div>
<script>
const pages = ['Overview', 'CPU', 'Memory', 'Network', 'Disk', 'Requests', 'Upstreams', 'Workers', 'Errors'];
const S = { data: null, hist: [] };
const nav = document.getElementById('nav');
pages.forEach((page) => {
const button = document.createElement('button');
button.className = `tab${page === 'Overview' ? ' active' : ''}`;
button.textContent = page;
button.onclick = () => show(page);
nav.appendChild(button);
});
function show(page) {
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.textContent === page));
document.querySelectorAll('.page').forEach((section) => section.classList.toggle('active', section.dataset.page === page));
drawAll();
}
function fmt(n, unit = '') {
if (n == null) return `0${unit}`;
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(2)}T${unit}`;
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(2)}G${unit}`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(2)}M${unit}`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(2)}K${unit}`;
return `${Number(n).toFixed(Number(n) % 1 ? 2 : 0)}${unit}`;
}
const bytes = (n) => fmt(n, 'B');
const pct = (n) => `${(Number(n) || 0).toFixed(1)}%`;
const ms = (n) => `${(Number(n) || 0).toFixed(1)}ms`;
function endpoint(path) {
const url = new URL(path, location.origin);
const token = new URLSearchParams(location.search).get('token');
if (token) url.searchParams.set('token', token);
return `${url.pathname}${url.search}`;
}
async function poll() {
try {
const response = await fetch(endpoint('/monitor/api'), { cache: 'no-store' });
if (!response.ok) throw new Error(response.status);
apply(await response.json(), true);
} catch (e) {
setConn(false);
}
}
function start() {
if (window.EventSource) {
const events = new EventSource(endpoint('/monitor/live'));
events.addEventListener('metrics', (event) => apply(JSON.parse(event.data), true));
events.onopen = () => setConn(true);
events.onerror = () => setConn(false);
}
poll();
setInterval(poll, 5000);
}
function setConn(ok) {
document.getElementById('dot').classList.toggle('ok', ok);
document.getElementById('conn').textContent = ok ? 'live' : 'offline';
}
function merge(target, source) {
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
function apply(data, ok) {
S.data = S.data && !data.history ? merge(S.data, data) : data;
if (data.history) S.hist = data.history;
document.getElementById('stamp').textContent = new Date((data.timestamp || 0) * 1000).toLocaleString();
setConn(ok);
render();
}
function card(label, value, sub = '') {
return `<div class="card"><div class="label">${label}</div><div class="value">${value}</div><div class="small">${sub}</div></div>`;
}
function render() {
const d = S.data;
if (!d) return;
const sys = d.system || {};
const req = d.requests || {};
const ng = d.nginx || {};
const net = d.network || {};
const disk = d.disk || {};
const mem = sys.memory || {};
const cpu = sys.cpu || {};
const lat = req.latency || {};
const st = req.status || {};
document.getElementById('cards').innerHTML = [
card('CPU', pct(cpu.usage), `${cpu.cores || 0} cores`),
card('Memory', pct(mem.used_pct), `${bytes(mem.used || 0)} used`),
card('RPS', fmt(req.requests_per_sec || ng.requests?.requests_per_sec || 0), `${fmt(req.total || 0)} total`),
card('Latency p95', ms(lat.p95), `p99 ${ms(lat.p99)}`),
card('Network', `${bytes(net.rx_bytes || 0)} / ${bytes(net.tx_bytes || 0)}`, 'rx / tx'),
card('Errors', fmt((st['4xx'] || 0) + (st['5xx'] || 0)), `${fmt(st['5xx'] || 0)} 5xx`)
].join('');
table('loadTable', [['Load 1m', cpu.load?.[0] || 0], ['Load 5m', cpu.load?.[1] || 0], ['Load 15m', cpu.load?.[2] || 0], ['Uptime', `${fmt(sys.uptime || 0)}s`]]);
table('memTable', [['Total', bytes(mem.total || 0)], ['Available', bytes(mem.available || 0)], ['Free', bytes(mem.free || 0)], ['Swap', pct(sys.swap?.used_pct || 0)]]);
rows('ifTable', net.interfaces || [], ['name', 'rx_bytes', 'tx_bytes', 'rx_packets', 'tx_packets', 'up'], 'ifFilter', 'ifSort');
rows('diskTable', disk.devices || [], ['name', 'reads', 'writes', 'read_bytes', 'write_bytes', 'io_ms']);
rows('fsTable', disk.filesystems || [], ['path', 'type', 'total', 'used', 'avail']);
rows('urlTable', req.top_urls || [], ['url', 'hits', 'errors', 'bytes', 'avg_latency'], 'urlFilter', 'urlSort');
rows('uaTable', req.user_agents || [], ['user_agent', 'hits', 'errors', 'avg_latency']);
rows('upTable', d.upstreams || [], ['peer', 'requests', 'failures', 'status_4xx', 'status_5xx', 'avg_latency']);
rows('workerTable', ng.workers || d.processes?.workers || [], ['slot', 'pid', 'active', 'requests', 'bytes', 'errors', 'vm_rss', 'last_seen']);
table('errTable', [['1xx', st['1xx'] || 0], ['2xx', st['2xx'] || 0], ['3xx', st['3xx'] || 0], ['4xx', st['4xx'] || 0], ['5xx', st['5xx'] || 0], ['Error rate', `${((req.error_rate || 0) * 100).toFixed(3)}%`]]);
drawAll();
}
function table(id, pairs) {
document.getElementById(id).innerHTML = `<tbody>${pairs.map((row) => `<tr><th>${row[0]}</th><td>${row[1]}</td></tr>`).join('')}</tbody>`;
}
function rows(id, data, cols, filterId, sortId) {
let rowsData = [...data];
const filter = filterId && document.getElementById(filterId)?.value.toLowerCase();
if (filter) rowsData = rowsData.filter((row) => JSON.stringify(row).toLowerCase().includes(filter));
const sort = sortId && document.getElementById(sortId)?.value;
if (sort) {
rowsData.sort((a, b) => typeof a[sort] === 'string'
? String(a[sort]).localeCompare(String(b[sort]))
: Number(b[sort] || 0) - Number(a[sort] || 0));
}
document.getElementById(id).innerHTML =
`<thead><tr>${cols.map((col) => `<th>${col}</th>`).join('')}</tr></thead>` +
`<tbody>${rowsData.map((row) => `<tr>${cols.map((col) => `<td class="${String(row[col]).length > 36 ? 'truncate' : ''}">${val(col, row[col])}</td>`).join('')}</tr>`).join('')}</tbody>`;
}
function val(key, value) {
if (key.includes('bytes') || ['total', 'used', 'avail', 'free'].includes(key)) return bytes(value || 0);
if (key.includes('latency')) return ms(value || 0);
return value == null ? '' : value;
}
['ifFilter', 'ifSort', 'urlFilter', 'urlSort'].forEach((id) => {
setTimeout(() => {
const element = document.getElementById(id);
if (element) element.oninput = render;
}, 0);
});
function drawAll() {
line('cpuChart', S.hist.map((x) => x.cpu), '#45d483', 100);
line('cpuChart2', S.hist.map((x) => x.cpu), '#45d483', 100);
line('rpsChart', S.hist.map((x) => x.rps), '#5aa9ff');
line('netChart', S.hist.map((x) => (x.network_rx_per_sec + x.network_tx_per_sec) / 1024), '#45d4d0');
line('latChart', S.hist.map((x) => x.latency_p95), '#f4c95d');
line('memChart', S.hist.map((x) => x.memory), '#ff5f6d', 100);
line('errChart', S.hist.map((x) => (x.status_4xx || 0) + (x.status_5xx || 0)), '#ff5f6d');
}
function line(id, values, color, max) {
const canvas = document.getElementById(id);
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.strokeStyle = '#263344';
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
const y = 10 + i * (rect.height - 20) / 3;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(rect.width, y);
ctx.stroke();
}
if (!values.length) return;
const ceiling = max || Math.max(...values, 1);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
values.forEach((value, index) => {
const x = index * Math.max(1, rect.width / (values.length - 1 || 1));
const y = rect.height - 8 - (Math.min(value, ceiling) / ceiling) * (rect.height - 18);
index ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
ctx.stroke();
}
start();
</script>
</body>
</html>