← back to Rentv 826 Tracker
feat: static /826/stats.html dashboard regenerated by the tracker (no nginx change); v1.1.0
cd977084639ac25bacfcef002d3e6850834d4bc6 · 2026-08-10 12:29:50 -0700 · steve
Files touched
M .env.exampleA lib/dashboard.jsM package.jsonM tracker.js
Diff
commit cd977084639ac25bacfcef002d3e6850834d4bc6
Author: steve <steve@designerwallcoverings.com>
Date: Mon Aug 10 12:29:50 2026 -0700
feat: static /826/stats.html dashboard regenerated by the tracker (no nginx change); v1.1.0
---
.env.example | 3 ++
lib/dashboard.js | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 2 +-
tracker.js | 13 +++++++--
4 files changed, 104 insertions(+), 3 deletions(-)
diff --git a/.env.example b/.env.example
index 31dbb07..16d5ed9 100644
--- a/.env.example
+++ b/.env.example
@@ -9,3 +9,6 @@ BACKFILL_LOG=/var/log/nginx/rentv.access.log
CLIENT_SLUG=boomer
# inactivity gap (minutes) that ends a session
SESSION_GAP_MIN=30
+# optional: regenerate a static dashboard into the /826/ static dir (served under the
+# existing Basic-Auth at /826/stats.html — no nginx change). Empty disables it.
+STATS_HTML=/var/www/rentv-826/stats.html
diff --git a/lib/dashboard.js b/lib/dashboard.js
new file mode 100644
index 0000000..f186894
--- /dev/null
+++ b/lib/dashboard.js
@@ -0,0 +1,89 @@
+'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 };
diff --git a/package.json b/package.json
index 607b03c..72aac57 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rentv-826-tracker",
- "version": "1.0.0",
+ "version": "1.1.0",
"private": true,
"description": "Server-side time-on-server tracker for the /826/ (Steve Bloom Concepts) client area on rentv.agentabrams.com. Sessionizes the nginx access log into the per-client Postgres DB rentv_826.",
"main": "tracker.js",
diff --git a/tracker.js b/tracker.js
index a16c781..5c7bac2 100644
--- a/tracker.js
+++ b/tracker.js
@@ -14,6 +14,7 @@ const readline = require('readline');
const { Pool } = require('pg');
const { parseLine } = require('./lib/parse');
const { isTrackable, ingestHit, closeStale } = require('./lib/sessionize');
+const { writeDashboard } = require('./lib/dashboard');
// --- config -------------------------------------------------------------
function loadEnv() {
@@ -36,8 +37,15 @@ const GAP_MS = (parseInt(process.env.SESSION_GAP_MIN, 10) || 30) * 60 * 1000;
const LIVE_LOG = process.env.LIVE_LOG || '/var/log/nginx/rentv-826.access.log';
const BACKFILL_LOG = process.env.BACKFILL_LOG || '/var/log/nginx/rentv.access.log';
const POLL_MS = 3000;
+const STATS_HTML = process.env.STATS_HTML || ''; // e.g. /var/www/rentv-826/stats.html; empty = disabled
const backfillOnly = process.argv.includes('--backfill-only');
+async function refreshDashboard() {
+ if (!STATS_HTML) return;
+ try { await writeDashboard(pool, CLIENT_SLUG, STATS_HTML, fs); }
+ catch (e) { log('dashboard write error:', e.message); }
+}
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
function log(...a) { console.log(new Date().toISOString(), ...a); }
@@ -129,12 +137,13 @@ async function tailOnce() {
}
async function liveLoop() {
- log('live-tailing', LIVE_LOG, `(client=${CLIENT_SLUG}, gap=${GAP_MS / 60000}min)`);
+ log('live-tailing', LIVE_LOG, `(client=${CLIENT_SLUG}, gap=${GAP_MS / 60000}min${STATS_HTML ? ', dashboard=' + STATS_HTML : ''})`);
+ await refreshDashboard();
let tick = 0;
for (;;) {
try {
await tailOnce();
- if (++tick % 20 === 0) await closeStale(pool, CLIENT_SLUG, GAP_MS); // ~every 60s
+ if (++tick % 20 === 0) { await closeStale(pool, CLIENT_SLUG, GAP_MS); await refreshDashboard(); } // ~every 60s
} catch (e) { log('tail error:', e.message); }
await new Promise((r) => setTimeout(r, POLL_MS));
}
← 5d17a72 single-source log mode: read+filter shared rentv.access.log,
·
back to Rentv 826 Tracker
·
(newest)