← back to Rentv 826 Tracker
lib/dashboard.js
90 lines
'use strict';
// Builds the /826/ time-on-server dashboard. The tracker regenerates it into
// /var/www/rentv-826/stats.html, so it is served by the EXISTING static /826/
// location behind the same Basic-Auth — no nginx change, no extra port.
function fmt(sec) {
sec = Math.round(sec || 0);
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
return `${h}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s`;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
}
function fmtWhen(d) {
if (!d) return '—';
return new Date(d).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
async function getStats(pool, client) {
const c = (await pool.query(`SELECT name, email FROM clients WHERE slug=$1`, [client])).rows[0] || { name: client };
const tot = (await pool.query(
`SELECT count(*) sessions, coalesce(sum(duration_seconds),0) secs, coalesce(sum(hit_count),0) hits,
min(started_at) first_seen, max(last_seen_at) last_seen
FROM sessions WHERE client_slug=$1`, [client])).rows[0];
const byUser = (await pool.query(
`SELECT coalesce(remote_user,'(anon)') u, count(*) n, coalesce(sum(duration_seconds),0) secs
FROM sessions WHERE client_slug=$1 GROUP BY 1 ORDER BY secs DESC`, [client])).rows;
const byDay = (await pool.query(
`SELECT to_char(date_trunc('day', started_at),'YYYY-MM-DD') d, count(*) n, coalesce(sum(duration_seconds),0) secs
FROM sessions WHERE client_slug=$1 GROUP BY 1 ORDER BY 1 DESC LIMIT 30`, [client])).rows;
const recent = (await pool.query(
`SELECT started_at, last_seen_at, duration_seconds, hit_count, remote_user, host(ip) ip
FROM sessions WHERE client_slug=$1 ORDER BY started_at DESC LIMIT 50`, [client])).rows;
return { c, client, tot, byUser, byDay, recent };
}
function renderHtml({ c, client, tot, byUser, byDay, recent }) {
const rows = (arr, cells) => arr.map((r) => `<tr>${cells(r)}</tr>`).join('');
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="60">
<title>Time on Server — ${esc(c.name)} · /826/</title>
<style>
:root{--bg:#0e131c;--panel:#111826;--line:#1c2536;--ink:#e8eef7;--mut:#8aa0bd;--acc:#5aa9ff}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1000px;margin:0 auto;padding:28px 20px 60px}
h1{font-size:22px;margin:0 0 4px}.sub{color:var(--mut);margin:0 0 22px;font-size:13px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:26px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.kpi .lab{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.04em}
.kpi .val{font-size:24px;font-weight:600;margin-top:4px}
h2{font-size:14px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em;margin:26px 0 10px}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--mut);font-weight:600;background:#0f1622}tr:last-child td{border-bottom:0}
td.num{text-align:right;font-variant-numeric:tabular-nums}.mono{font-variant-numeric:tabular-nums}
.foot{color:var(--mut);font-size:12px;margin-top:26px}
</style></head><body><div class="wrap">
<h1>Time on Server — ${esc(c.name)}</h1>
<p class="sub">/826/ · ${esc(c.email || '')} · client <code>${esc(client)}</code></p>
<div class="kpis">
<div class="kpi"><div class="lab">Total time on server</div><div class="val">${fmt(tot.secs)}</div></div>
<div class="kpi"><div class="lab">Sessions</div><div class="val">${tot.sessions}</div></div>
<div class="kpi"><div class="lab">Page hits</div><div class="val">${tot.hits}</div></div>
<div class="kpi"><div class="lab">Last seen</div><div class="val" style="font-size:15px">${esc(fmtWhen(tot.last_seen))}</div></div>
</div>
<h2>By login</h2>
<table><thead><tr><th>Login</th><th class="num">Time</th><th class="num">Sessions</th></tr></thead>
<tbody>${rows(byUser, (r) => `<td>${esc(r.u)}</td><td class="num mono">${fmt(r.secs)}</td><td class="num mono">${r.n}</td>`) || '<tr><td colspan=3>No data yet</td></tr>'}</tbody></table>
<h2>By day</h2>
<table><thead><tr><th>Day</th><th class="num">Time</th><th class="num">Sessions</th></tr></thead>
<tbody>${rows(byDay, (r) => `<td class="mono">${esc(r.d)}</td><td class="num mono">${fmt(r.secs)}</td><td class="num mono">${r.n}</td>`) || '<tr><td colspan=3>No data yet</td></tr>'}</tbody></table>
<h2>Recent sessions</h2>
<table><thead><tr><th>Started</th><th>Login</th><th>IP</th><th class="num">Duration</th><th class="num">Hits</th></tr></thead>
<tbody>${rows(recent, (r) => `<td class="mono">${esc(fmtWhen(r.started_at))}</td><td>${esc(r.remote_user || '(anon)')}</td><td class="mono">${esc(r.ip)}</td><td class="num mono">${fmt(r.duration_seconds)}</td><td class="num mono">${r.hit_count}</td>`) || '<tr><td colspan=5>No sessions yet</td></tr>'}</tbody></table>
<p class="foot">First seen ${esc(fmtWhen(tot.first_seen))} · generated ${esc(new Date().toLocaleString())} · auto-refresh 60s</p>
</div></body></html>`;
}
async function writeDashboard(pool, client, outPath, fs) {
const stats = await getStats(pool, client);
const html = renderHtml(stats);
const tmp = outPath + '.tmp';
fs.writeFileSync(tmp, html);
fs.renameSync(tmp, outPath); // atomic swap so nginx never serves a half-written file
}
module.exports = { getStats, renderHtml, writeDashboard, fmt };