Back to DevLog

Building an Admin Panel for User Metrics and AI Call Logging

5 min read

I built an admin panel to see who's using EpsteinScan and added logging for AI calls that had been failing silently for five months.

The Admin Users Panel

I added a new /admin/users route that shows registered accounts in a sortable table. The columns are email, plan, signup date, last login, AI call count, last AI call, and signup source. You can sort by email, signup date, last login, or AI call count through an ORDER BY whitelist. The default sort is newest signups first.

Four metric tiles sit at the top: total accounts, paid accounts, AI calls by registered users, and AI calls by anonymous visitors.

I added signup source tracking that captures first-touch referrer and landing path. It's written to sessionStorage client-side and posted as hidden fields at registration. I used sessionStorage instead of Flask sessions because writing the session on every anonymous GET would set cookies sitewide and make every HTML page uncacheable at nginx and Cloudflare.

The last_login column gets set on successful login, throttled to one UPDATE per UTC day in the user_loader function.

AI Call Logging

The three exception handlers in the answer stream were swallowing errors without logging them. I added a new ai_call_log table and made all three handlers write warnings and log entries when they fire.

I also added an AI model health strip to /admin/analytics that shows the answer model, extract model, calls today, errors in the last 24 hours, the last error message, and an ASK_ENABLED warning if needed. The strip gets a red left border when errors are greater than zero.

To verify the error path worked, I temporarily pointed ASK_MODEL_ANSWER at a nonexistent model, exercised the handler, reverted the change, and proved byte-identity with diff.

Header Ask AI Button

I added a sitewide "Ask AI" button to the header search bar in base.html. It reuses the same livePulse keyframes as the LIVE badge for the nav AI badge, so they pulse in sync.

The button carries the query through to /ai-analyst?q=... as a prefill only. It never auto-submits.

I added a small muted lowercase "or" between the magnifier and Ask AI button and removed the 1px divider. When the input is prefilled via ?q=, the AI send button gets a hot-send accent glow using the same livePulse timing. The glow stops on first user interaction and never fires on an empty or manually-typed input.

Schema Choices

I put signup source in a sibling table instead of adding nullable columns to users. The User class is built via User(**dict(row)) over SELECT * with a fixed signature, and auth.db is shared with production. An ALTER TABLE users would have broken prod instantly. A new table is invisible to that constructor.

I restructured User.create() so the IntegrityError wraps only the INSERT and source recording runs post-commit, which means it can never fail a signup.

Blog Disclaimer Safety Net

I extended the fairness disclaimer check to /api/blog/publish so the manual publish path matches the generator path. The check runs before the upsert branches so both INSERT and UPDATE store the same body and a re-publish is covered.

I dry-ran the logic against a scratch database, not dev. The blog_posts table lives in epstein_ocr.db, which both dev and prod symlink to the same shared file. A test publish on dev would have appeared on production's /blog instantly. I built a scratch DB holding a copy of the 89-row people table plus an empty blog_posts, pointed DATABASE_PATH at it, and drove the real route through Flask's test client. I confirmed containment afterward by counting slug LIKE 'dryrun-%' in the shared DB, which returned zero.

I duplicated DISCLAIMER_HTML into web_search.py instead of importing it from scripts/auto_blog.py because that module does load_dotenv() and imports anthropic at module level. The app already treats it as a subprocess, not a library. I verified the two literals were byte-identical.

Security Cleanup

I found two Supervisor config backups holding 13 credentials inline in the environment= line. The file from March 1 was the problem; the July 30 backup was already clean because it came after the .env migration. Both were mode 0644, readable by the app user while .env itself is 0600. I shredded both.

Supervisor's include glob is *.conf, so neither .bak file had ever been loaded.

Three credentials from those backups are still live and need rotating: ADMIN_PASSWORD, COMMAND_CENTER_SECRET, and FLASK_SECRET_KEY. Rotating FLASK_SECRET_KEY will invalidate every active session and remember-me cookie. Two other files still hold the same three live secrets: /home/epsteinscan/.env.bak-20260625-secretrotate (mode 0600) and /home/epsteinscan/app-dev/deploy/supervisor.conf (mode 0644, world-readable).

I ran git log --all -S for all seven checked secret values and got zero commits. The deploy/ directory is gitignored, which is why the plaintext deploy/supervisor.conf never reached GitHub.

Template Line-Ending Problem

I shipped a diff showing 913 lines changed in admin_analytics.html and 240 in admin_base.html, but git diff -w showed only 85 and 34. The cause was that patch_admin.py read the files in Python text mode, so universal-newline translation stripped CRLF before the backup was even written. I proved the backups were content-identical to HEAD when CR-normalized, restored CRLF wholesale, then put back the one 3-line LF-only block in admin_base.html.

Playwright Harness Issue

The ai_call_log table was empty after the first AI call because the Playwright harness closed the page mid-SSE, killing the generator so no post-stream code ran. I fixed it by polling until the rendered answer text stopped growing.

Share this post