← 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };