← back to George Gmail
Fleet auto-responder admin UI dashboard (Express viewer, port 9766, pm2-managed)
fb57ee902a92022885a7e06ee9771909f87cb422 · 2026-05-19 10:14:36 -0700 · Steve Abrams
Files touched
M .gitignoreA fleet-autoresponder-ui.jsA public-fleet-ui/index.html
Diff
commit fb57ee902a92022885a7e06ee9771909f87cb422
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 19 10:14:36 2026 -0700
Fleet auto-responder admin UI dashboard (Express viewer, port 9766, pm2-managed)
---
.gitignore | 4 +-
fleet-autoresponder-ui.js | 107 ++++++++++++++++++++++++++++
public-fleet-ui/index.html | 169 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 279 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 46948d6..311ecc8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,7 @@ dist/
build/
.next/
*.bak
-# fleet-autoresponder runtime ledger (per-sender reply timestamps — not source)
+# fleet-autoresponder runtime data (per-sender timestamps, run-log, state — not source)
data/fleet-autoresponder-ledger.json
+data/fleet-autoresponder-runlog.jsonl
+data/fleet-autoresponder-state.json
diff --git a/fleet-autoresponder-ui.js b/fleet-autoresponder-ui.js
new file mode 100644
index 0000000..26c2a47
--- /dev/null
+++ b/fleet-autoresponder-ui.js
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+/**
+ * Fleet Auto-Responder — admin UI dashboard.
+ *
+ * A small standalone Express viewer (127.0.0.1:9766) for the fleet
+ * auto-responder. Deliberately NOT part of the George prod server — it
+ * reads the responder's own data files (ledger + run-log + state) so it
+ * needs no George deploy and no Gmail OAuth.
+ *
+ * Shows:
+ * - inquiries received per fleet domain
+ * - auto-replies sent (timestamp + recipient)
+ * - spam-skips with the reason each was skipped
+ * - the current reply template (HTML preview)
+ * - an on/off toggle for the responder
+ * - last-run time
+ *
+ * Data sources (all written by fleet-autoresponder.js):
+ * data/fleet-autoresponder-ledger.json per-sender reply timestamps
+ * data/fleet-autoresponder-runlog.jsonl append-only per-message records
+ * data/fleet-autoresponder-state.json last-run + on/off toggle
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+
+const responder = require('./fleet-autoresponder');
+const { RUNLOG_PATH, STATE_PATH, LEDGER_PATH, FLEET,
+ buildHtmlBody, buildPlainBody, loadState, saveState } = responder;
+
+const PORT = process.env.FLEET_UI_PORT || 9766;
+const app = express();
+app.use(express.json());
+
+// ─── Data readers ────────────────────────────────────────────────────────────
+function readRunLog() {
+ try {
+ return fs.readFileSync(RUNLOG_PATH, 'utf8')
+ .split('\n').filter(Boolean)
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
+ .filter(Boolean);
+ } catch { return []; }
+}
+function readLedger() {
+ try { return JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')); }
+ catch { return { replies: {} }; }
+}
+
+// ─── API ─────────────────────────────────────────────────────────────────────
+app.get('/api/data', (req, res) => {
+ const log = readRunLog();
+ const state = loadState();
+ const ledger = readLedger();
+
+ const replies = log.filter(r => r.event === 'reply');
+ const skips = log.filter(r => r.event === 'skip');
+
+ // Per-domain inquiry tally (a "received" inquiry = anything the responder
+ // touched — replied OR skipped — that resolved to a fleet domain).
+ const perDomain = {};
+ for (const s of FLEET) perDomain[s.domain] = { domain: s.domain, siteName: s.siteName, received: 0, replied: 0, skipped: 0 };
+ for (const r of log) {
+ if (!r.fleetDomain || !perDomain[r.fleetDomain]) continue;
+ perDomain[r.fleetDomain].received++;
+ if (r.event === 'reply') perDomain[r.fleetDomain].replied++;
+ if (r.event === 'skip') perDomain[r.fleetDomain].skipped++;
+ }
+
+ res.json({
+ state,
+ fleetCount: FLEET.length,
+ totals: {
+ inquiries: log.length,
+ replied: replies.length,
+ skipped: skips.length,
+ uniqueSenders: Object.keys(ledger.replies || {}).length,
+ },
+ perDomain: Object.values(perDomain).sort((a, b) => b.received - a.received),
+ replies: replies.slice(-200).reverse(),
+ skips: skips.slice(-200).reverse(),
+ });
+});
+
+// Current reply template — rendered for a sample domain so the UI can preview.
+app.get('/api/template', (req, res) => {
+ const domain = (req.query.domain || (FLEET[0] && FLEET[0].domain) || 'example.com').toLowerCase();
+ res.json({ domain, html: buildHtmlBody(domain), text: buildPlainBody(domain) });
+});
+
+// On/off toggle for the responder.
+app.post('/api/toggle', (req, res) => {
+ const state = loadState();
+ state.enabled = !!req.body.enabled;
+ state.toggledAt = new Date().toISOString();
+ saveState(state);
+ res.json({ ok: true, enabled: state.enabled });
+});
+
+app.get('/healthz', (_req, res) => res.json({ ok: true }));
+
+app.use(express.static(path.join(__dirname, 'public-fleet-ui')));
+
+app.listen(PORT, '127.0.0.1', () => {
+ console.log(`[fleet-autoresponder-ui] listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/public-fleet-ui/index.html b/public-fleet-ui/index.html
new file mode 100644
index 0000000..e9a04f2
--- /dev/null
+++ b/public-fleet-ui/index.html
@@ -0,0 +1,169 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Fleet Auto-Responder · Dashboard</title>
+<style>
+ :root { --bg:#14110d; --card:#1d1913; --ink:#f3efe8; --mut:#a89c88;
+ --accent:#7d3c00; --gold:#c9a35e; --rule:#352e23;
+ --ok:#3f7d4a; --warn:#9a6b1f; --bad:#8a3030; }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ body { background:var(--bg); color:var(--ink); font:14px/1.5 -apple-system,Inter,system-ui,sans-serif; }
+ a { color:var(--gold); }
+ header { padding:20px 28px; border-bottom:1px solid var(--rule);
+ display:flex; align-items:center; gap:20px; flex-wrap:wrap; }
+ h1 { font:600 18px/1 Georgia,serif; letter-spacing:.4px; }
+ h1 small { color:var(--mut); font:400 12px/1 -apple-system,sans-serif; margin-left:8px; }
+ .wrap { padding:24px 28px; max-width:1200px; }
+ .row { display:flex; gap:16px; flex-wrap:wrap; }
+ .card { background:var(--card); border:1px solid var(--rule); border-radius:8px; padding:16px 18px; }
+ .stat { flex:1 1 140px; }
+ .stat .n { font:600 26px/1 Georgia,serif; }
+ .stat .l { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.6px; margin-top:6px; }
+ section { margin-top:24px; }
+ section h2 { font:600 13px/1 -apple-system,sans-serif; text-transform:uppercase;
+ letter-spacing:.8px; color:var(--gold); margin-bottom:10px; }
+ table { width:100%; border-collapse:collapse; font-size:13px; }
+ th { text-align:left; color:var(--mut); font-weight:600; font-size:11px;
+ text-transform:uppercase; letter-spacing:.5px; padding:8px 10px; border-bottom:1px solid var(--rule); }
+ td { padding:7px 10px; border-bottom:1px solid var(--rule); vertical-align:top; }
+ tr:last-child td { border-bottom:none; }
+ .mono { font-family:ui-monospace,Menlo,monospace; font-size:12px; }
+ .pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
+ .pill.ok { background:rgba(63,125,74,.2); color:#86d693; }
+ .pill.skip { background:rgba(154,107,31,.2); color:#e0b56a; }
+ .pill.off { background:rgba(138,48,48,.25); color:#e09090; }
+ .toggle { display:flex; align-items:center; gap:10px; margin-left:auto; }
+ .switch { position:relative; width:48px; height:26px; }
+ .switch input { opacity:0; width:0; height:0; }
+ .slider { position:absolute; inset:0; background:#5a5145; border-radius:26px; cursor:pointer; transition:.2s; }
+ .slider:before { content:""; position:absolute; height:20px; width:20px; left:3px; top:3px;
+ background:#fff; border-radius:50%; transition:.2s; }
+ input:checked + .slider { background:var(--ok); }
+ input:checked + .slider:before { transform:translateX(22px); }
+ .preview { background:#faf7f2; border:1px solid var(--rule); border-radius:6px; overflow:hidden; }
+ .preview iframe { width:100%; height:340px; border:0; background:#faf7f2; }
+ .muted { color:var(--mut); }
+ .scroll { max-height:380px; overflow:auto; }
+ .empty { color:var(--mut); padding:14px 10px; font-style:italic; }
+ select { background:var(--card); color:var(--ink); border:1px solid var(--rule);
+ border-radius:5px; padding:5px 8px; font-size:12px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>Fleet Auto-Responder <small id="fleetcount"></small></h1>
+ <div class="muted" id="lastrun">last run: —</div>
+ <div class="toggle">
+ <span class="muted">Responder</span>
+ <label class="switch">
+ <input type="checkbox" id="enabled">
+ <span class="slider"></span>
+ </label>
+ <span id="enabledLabel" class="pill off">…</span>
+ </div>
+</header>
+
+<div class="wrap">
+ <div class="row">
+ <div class="card stat"><div class="n" id="s-inq">—</div><div class="l">Inquiries seen</div></div>
+ <div class="card stat"><div class="n" id="s-rep">—</div><div class="l">Auto-replies sent</div></div>
+ <div class="card stat"><div class="n" id="s-skip">—</div><div class="l">Spam / skipped</div></div>
+ <div class="card stat"><div class="n" id="s-snd">—</div><div class="l">Unique senders (ledger)</div></div>
+ </div>
+
+ <section>
+ <h2>Auto-replies sent</h2>
+ <div class="card scroll">
+ <table><thead><tr><th>Time</th><th>Recipient</th><th>Domain</th><th>Subject</th></tr></thead>
+ <tbody id="replies"></tbody></table>
+ </div>
+ </section>
+
+ <section>
+ <h2>Spam-skips & reasons</h2>
+ <div class="card scroll">
+ <table><thead><tr><th>Time</th><th>Sender</th><th>Domain</th><th>Skip reason</th></tr></thead>
+ <tbody id="skips"></tbody></table>
+ </div>
+ </section>
+
+ <section>
+ <h2>Inquiries per fleet domain</h2>
+ <div class="card scroll">
+ <table><thead><tr><th>Domain</th><th>Site</th><th>Received</th><th>Replied</th><th>Skipped</th></tr></thead>
+ <tbody id="perdomain"></tbody></table>
+ </div>
+ </section>
+
+ <section>
+ <h2>Current reply template (HTML preview)
+ <select id="tplDomain"></select></h2>
+ <div class="preview"><iframe id="tplFrame" title="reply preview"></iframe></div>
+ </section>
+</div>
+
+<script>
+const $ = id => document.getElementById(id);
+function esc(s){ return String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
+function fmt(ts){ if(!ts) return '—'; const d=new Date(ts); return d.toLocaleString(); }
+
+async function load() {
+ const d = await fetch('/api/data').then(r=>r.json());
+ $('fleetcount').textContent = d.fleetCount + ' domains';
+ $('lastrun').textContent = 'last run: ' + fmt(d.state.lastRun) +
+ (d.state.lastRunResult ? ' · replied ' + (d.state.lastRunResult.replied||0) +
+ ' / skipped ' + (d.state.lastRunResult.skipped||0) : '');
+ $('s-inq').textContent = d.totals.inquiries;
+ $('s-rep').textContent = d.totals.replied;
+ $('s-skip').textContent = d.totals.skipped;
+ $('s-snd').textContent = d.totals.uniqueSenders;
+
+ const en = d.state.enabled !== false;
+ $('enabled').checked = en;
+ $('enabledLabel').textContent = en ? 'ON' : 'OFF';
+ $('enabledLabel').className = 'pill ' + (en ? 'ok' : 'off');
+
+ $('replies').innerHTML = d.replies.length ? d.replies.map(r =>
+ `<tr><td class="mono">${fmt(r.ts)}</td><td>${esc(r.recipient)}</td>
+ <td>${esc(r.fleetDomain)}</td><td>${esc(r.subject)}${r.dryRun?' <span class="pill skip">dry-run</span>':''}</td></tr>`
+ ).join('') : '<tr><td colspan="4" class="empty">No replies logged yet.</td></tr>';
+
+ $('skips').innerHTML = d.skips.length ? d.skips.map(r =>
+ `<tr><td class="mono">${fmt(r.ts)}</td><td>${esc(r.sender)}</td>
+ <td>${esc(r.fleetDomain||'—')}</td><td><span class="pill skip">skip</span> ${esc(r.reason)}</td></tr>`
+ ).join('') : '<tr><td colspan="4" class="empty">No skips logged yet.</td></tr>';
+
+ const pd = d.perDomain.filter(x=>x.received>0);
+ $('perdomain').innerHTML = pd.length ? pd.map(x =>
+ `<tr><td class="mono">${esc(x.domain)}</td><td>${esc(x.siteName)}</td>
+ <td>${x.received}</td><td>${x.replied}</td><td>${x.skipped}</td></tr>`
+ ).join('') : '<tr><td colspan="5" class="empty">No fleet inquiries seen yet.</td></tr>';
+
+ if (!$('tplDomain').dataset.filled) {
+ $('tplDomain').innerHTML = d.perDomain.map(x =>
+ `<option value="${esc(x.domain)}">${esc(x.domain)}</option>`).join('');
+ $('tplDomain').dataset.filled = '1';
+ loadTemplate();
+ }
+}
+
+async function loadTemplate() {
+ const dom = $('tplDomain').value;
+ const t = await fetch('/api/template?domain=' + encodeURIComponent(dom)).then(r=>r.json());
+ $('tplFrame').srcdoc = t.html;
+}
+
+$('enabled').addEventListener('change', async e => {
+ await fetch('/api/toggle', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ enabled: e.target.checked }) });
+ load();
+});
+$('tplDomain').addEventListener('change', loadTemplate);
+
+load();
+setInterval(load, 15000);
+</script>
+</body>
+</html>
← 13d4e3b Fleet auto-responder: multipart/alternative HTML+plain reply
·
back to George Gmail
·
Fleet auto-responder: pm2 ecosystem config — 3-min cron sche d6e30ce →