feat: add visitor identity cookie and database reset job
Three connected features for the deployment-strategy demo: 1. Visitor identity cookie (v3). A new visitors table backs a first-party gb_visitor cookie: middleware on /api checks the cookie's UUID against the database on every request and regenerates + persists it when the id is missing, malformed, or unknown (fresh browser, pruned identity, or a reset database). The identity remembers the name a visitor signed with (pre-filled next time, used for unsigned posts), replaces the standalone reaction cookie for reaction de-dupe, and feeds a live "N visiting" counter (identities seen in the last 5 minutes) shown on the wall, in /api/info, the admin overview, and the presence/tick SSE events. The admin console gains Visitors/Visiting tiles and a visitors table, plus a GET /api/me endpoint and GET /api/admin/visitors. 2. guestbook reset command. Wipes entries (including soft-deleted rows), reactions and visitor identities under the schema advisory lock, with --dry-run, --hard (also reset wall settings) and --reseed flags; meant to run as a one-off Miabi Job. After a reset every outstanding cookie is invalidated, so browsers get a fresh identity on their next request. 3. cleanup learns --prune-visitors (default 30 days): the cron job now also forgets idle identities together with their reactions. Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity talking point is in act 3, and the API tables list the new endpoints. Akili-Task: tsk_ptdi5ufndrzwlljk4v5g Akili-Agent: goldfish
This commit is contained in:
@@ -108,6 +108,8 @@
|
||||
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||
tr.me td { color: #fff; }
|
||||
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
|
||||
td.fresh { color: var(--ok); }
|
||||
td.fresh::before { content: "● "; font-size: 10px; }
|
||||
dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
|
||||
dl.kv dt { color: var(--muted); }
|
||||
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||
@@ -216,6 +218,8 @@
|
||||
<div id="tab-overview" data-panel>
|
||||
<div class="tiles">
|
||||
<div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div>
|
||||
<div class="card tile"><div class="label">Visiting</div><div class="value" id="tVisiting" title="Visitor identities seen in the last 5 minutes">–</div></div>
|
||||
<div class="card tile"><div class="label">Visitors</div><div class="value" id="tVisitors" title="Identity cookies the app has issued (rows in the visitors table)">–</div></div>
|
||||
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</div></div>
|
||||
<div class="card tile"><div class="label">Signatures</div><div class="value" id="tTotal">–</div></div>
|
||||
<div class="card tile"><div class="label">Today</div><div class="value" id="tToday">–</div></div>
|
||||
@@ -242,6 +246,14 @@
|
||||
<dl class="kv" id="runtime"></dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card pad">
|
||||
<h2>Visitors</h2>
|
||||
<p class="hint">Every browser gets an identity cookie (<code>gb_visitor</code>); the backend accepts it only while its id exists in the database — after a <code>guestbook reset</code> every cookie is regenerated on the next request. Idle identities are pruned by the <code>cleanup</code> job (<code>--prune-visitors</code>).</p>
|
||||
<table>
|
||||
<thead><tr><th>Visitor</th><th>Name</th><th>Signatures</th><th>First seen</th><th>Last seen</th></tr></thead>
|
||||
<tbody id="visitorRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,6 +457,8 @@
|
||||
$('appName').textContent = rt.app;
|
||||
setVersion(rt.version);
|
||||
$('tOnline').textContent = online;
|
||||
$('tVisiting').textContent = overview.visitors ?? 0;
|
||||
$('tVisitors').textContent = stats.visitors ?? 0;
|
||||
$('tReplicas').textContent = replicas.length;
|
||||
$('tTotal').textContent = stats.total;
|
||||
$('tToday').textContent = stats.today;
|
||||
@@ -452,6 +466,7 @@
|
||||
$('tPinned').textContent = stats.pinned;
|
||||
$('tHidden').textContent = stats.hidden;
|
||||
renderChart(stats.daily || []);
|
||||
loadVisitors();
|
||||
|
||||
$('replicaHint').innerHTML = rt.broker === 'redis'
|
||||
? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.'
|
||||
@@ -502,6 +517,22 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---- visitors ----------------------------------------------------------
|
||||
async function loadVisitors() {
|
||||
const r = await req('GET', '/api/admin/visitors?limit=25');
|
||||
if (!r.ok || !r.data) return;
|
||||
const activeSince = new Date(r.data.active_since || 0).getTime();
|
||||
const rows = r.data.visitors || [];
|
||||
$('visitorRows').innerHTML = rows.length ? rows.map((v) => `
|
||||
<tr>
|
||||
<td class="mono">${esc(String(v.id).slice(0, 8))}…</td>
|
||||
<td>${esc(v.name || '–')}</td>
|
||||
<td>${v.signatures}</td>
|
||||
<td title="${esc(new Date(v.first_seen).toLocaleString())}">${ago(v.first_seen)}</td>
|
||||
<td class="${new Date(v.last_seen).getTime() >= activeSince ? 'fresh' : ''}" title="${esc(new Date(v.last_seen).toLocaleString())}">${ago(v.last_seen)}</td>
|
||||
</tr>`).join('') : '<tr><td colspan="5" class="empty">No visitor identities yet — they are created on the first request.</td></tr>';
|
||||
}
|
||||
|
||||
// ---- moderation --------------------------------------------------------
|
||||
const mod = { status: '', q: '', offset: 0 };
|
||||
async function loadRows() {
|
||||
|
||||
+36
-5
@@ -215,6 +215,10 @@
|
||||
.paused b { color: var(--text); }
|
||||
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
|
||||
background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; }
|
||||
.clock-meta .you { color: var(--text); font-weight: 600; }
|
||||
.visitors { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted);
|
||||
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border);
|
||||
white-space: nowrap; }
|
||||
|
||||
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
|
||||
|
||||
@@ -257,7 +261,8 @@
|
||||
<div class="status">
|
||||
<span class="ver" id="ver" title="Serving version">v…</span>
|
||||
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
|
||||
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||
<span class="visitors" id="visitors" title="Visitor identities active in the last 5 minutes (issued by the identity cookie)" hidden>👤 <span id="visitorCount">0</span></span>
|
||||
<span class="online" title="Browsers connected to the live stream right now"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||
<button class="icon-btn" id="themeBtn" title="Toggle light / dark theme" aria-label="Toggle light / dark theme">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,6 +285,8 @@
|
||||
<div class="clock-time" id="clockTime">—</div>
|
||||
</div>
|
||||
<div class="clock-meta" id="clockMeta">waiting for server…</div>
|
||||
<!-- Visitor identity (new in v3): the server recognises this browser -->
|
||||
<div class="clock-meta" id="visitorMeta" hidden></div>
|
||||
</div>
|
||||
|
||||
<!-- Home view -->
|
||||
@@ -347,6 +354,7 @@
|
||||
|
||||
const api = {
|
||||
info: () => fetch('/api/info').then(r => r.json()),
|
||||
me: () => fetch('/api/me').then(r => r.json()),
|
||||
time: () => fetch('/api/time').then(r => r.json()),
|
||||
settings: () => fetch('/api/settings').then(r => r.json()),
|
||||
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
|
||||
@@ -486,6 +494,28 @@
|
||||
$('replicas').hidden = false;
|
||||
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
|
||||
}
|
||||
function setVisitors(n) {
|
||||
if (!n) return;
|
||||
$('visitors').hidden = false;
|
||||
$('visitorCount').textContent = n;
|
||||
}
|
||||
|
||||
// ---- visitor identity ---------------------------------------------------
|
||||
// The server recognises this browser by the gb_visitor cookie: it checks
|
||||
// the cookie's id against the database and hands out a new one when it is
|
||||
// unknown (first visit, pruned identity, or a wiped database). /api/me
|
||||
// reports who the server thinks we are; the remembered name pre-fills the
|
||||
// form.
|
||||
function showVisitor(v) {
|
||||
if (!v || !v.id) return;
|
||||
const el = $('visitorMeta');
|
||||
el.hidden = false;
|
||||
const bits = [`you are <span class="you">${esc(v.name || 'guest-' + String(v.id).slice(0, 8))}</span>`];
|
||||
if (v.signatures > 0) bits.push(`${v.signatures} signature${v.signatures === 1 ? '' : 's'}`);
|
||||
if (v.first_seen) bits.push(`since ${timeAgo(v.first_seen)}`);
|
||||
el.innerHTML = bits.join(' · ');
|
||||
if (v.name && !$('name').value) $('name').value = v.name;
|
||||
}
|
||||
function applySettings(st) {
|
||||
const banner = (st && st.banner) || '';
|
||||
$('banner').hidden = !banner;
|
||||
@@ -585,8 +615,8 @@
|
||||
let connectedOnce = false;
|
||||
function connectSSE() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
|
||||
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
|
||||
es.addEventListener('updated', () => reloadView());
|
||||
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
||||
@@ -594,7 +624,7 @@
|
||||
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
|
||||
// Resync after a reconnect: events sent while disconnected were missed.
|
||||
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });
|
||||
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); });
|
||||
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); setVisitors(d.visitors); });
|
||||
// EventSource auto-reconnects on error; nothing to do here.
|
||||
}
|
||||
|
||||
@@ -620,10 +650,11 @@
|
||||
|
||||
// ---- boot ---------------------------------------------------------------
|
||||
api.info().then((d) => {
|
||||
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length);
|
||||
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length); setVisitors(d.visitors);
|
||||
$('adminLink').hidden = !d.admin;
|
||||
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
|
||||
}).catch(() => {});
|
||||
api.me().then(showVisitor).catch(() => {});
|
||||
api.settings().then(applySettings).catch(() => {});
|
||||
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
||||
route();
|
||||
|
||||
Reference in New Issue
Block a user