Show Reddit recovery status in dashboard

This commit is contained in:
jjbrunton committed 2026-08-08 14:42:19 +01:00
1 parent d2b3a1b31d
commit 316dc3dd3e
6 files changed
+208 -5

No files matched your search

+4
View File
@@ -22,12 +22,16 @@ services:
- "6080" # noVNC for human login - "6080" # noVNC for human login
environment: environment:
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
# Required whenever the web dashboard is enabled; never commit its value.
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
- REDDIT_USERNAME=${REDDIT_USERNAME} - REDDIT_USERNAME=${REDDIT_USERNAME}
- REDDIT_PASSWORD=${REDDIT_PASSWORD} - REDDIT_PASSWORD=${REDDIT_PASSWORD}
# Chromium runs headed under Xvfb so a human can complete login over noVNC. # Chromium runs headed under Xvfb so a human can complete login over noVNC.
- BROWSER_HEADLESS=false - BROWSER_HEADLESS=false
# Required to lock down the VNC endpoint. # Required to lock down the VNC endpoint.
- VNC_PASSWORD=${VNC_PASSWORD} - VNC_PASSWORD=${VNC_PASSWORD}
# Public Traefik route used by the dashboard's recovery button.
- NOVNC_PUBLIC_URL=https://red-vnc.apps.jbrunton.co.uk
volumes: volumes:
reddit_bot_data: reddit_bot_data:
+78 -3
View File
@@ -193,6 +193,47 @@
gap: 10px; gap: 10px;
} }
.reddit-session-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 18px 0 0;
padding: 15px 18px;
border: 1px solid rgba(184, 92, 92, .42);
border-radius: var(--r-md);
background: var(--clay-soft);
color: #7d3939;
box-shadow: var(--shadow-sm);
}
.reddit-session-banner.unreachable {
border-color: rgba(212, 165, 116, .62);
background: var(--honey-soft);
color: #745022;
}
.reddit-session-banner[hidden] { display: none; }
.reddit-session-banner strong {
display: block;
margin-bottom: 3px;
font-family: var(--f-display);
font-size: 16px;
}
.reddit-session-banner span {
font-size: 12px;
line-height: 1.45;
}
@media (max-width: 680px) {
.reddit-session-banner {
align-items: flex-start;
flex-direction: column;
}
}
/* ───── Status badges ───── */ /* ───── Status badges ───── */
.status-badge { .status-badge {
padding: 6px 14px; padding: 6px 14px;
@@ -2288,6 +2329,14 @@
<div class="container"> <div class="container">
<div id="reddit-session-banner" class="reddit-session-banner" role="alert" hidden>
<div>
<strong id="reddit-session-banner-title">Reddit login required</strong>
<span id="reddit-session-banner-detail">Chat sync is paused until the browser session is restored.</span>
</div>
<button class="btn-primary" type="button" onclick="openLoginModal()">Open Reddit Login</button>
</div>
<div id="cooldown-banner" style="display:none; margin:1rem 0; padding:0.85rem 1rem; border-radius:8px; background:#fff4e5; border:1px solid #f5b86a; color:#7a4a00; font-size:0.92rem; line-height:1.4;"> <div id="cooldown-banner" style="display:none; margin:1rem 0; padding:0.85rem 1rem; border-radius:8px; background:#fff4e5; border:1px solid #f5b86a; color:#7a4a00; font-size:0.92rem; line-height:1.4;">
<strong>⏸️ Chat throttle active.</strong> <strong>⏸️ Chat throttle active.</strong>
<span id="cooldown-reason"></span> <span id="cooldown-reason"></span>
@@ -3957,9 +4006,13 @@
let loginInfoCache = null; let loginInfoCache = null;
function buildNovncUrl(port) { function buildNovncUrl(info) {
if (info.publicUrl) {
const base = info.publicUrl.replace(/\/$/, '');
return `${base}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
}
const proto = window.location.protocol === 'https:' ? 'https:' : 'http:'; const proto = window.location.protocol === 'https:' ? 'https:' : 'http:';
return `${proto}//${window.location.hostname}:${port}/vnc.html?autoconnect=1&resize=scale&reconnect=1`; return `${proto}//${window.location.hostname}:${info.novncPort}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
} }
async function fetchLoginInfo() { async function fetchLoginInfo() {
@@ -3986,6 +4039,7 @@
} else { } else {
banner.innerHTML = 'Not logged in. Click <strong>Open Reddit login page</strong>, complete the login in the embedded browser, then click <strong>Re-check session</strong>.'; banner.innerHTML = 'Not logged in. Click <strong>Open Reddit login page</strong>, complete the login in the embedded browser, then click <strong>Re-check session</strong>.';
} }
updateRedditSessionBanner(data);
} catch (err) { } catch (err) {
banner.textContent = 'Could not check session status.'; banner.textContent = 'Could not check session status.';
} }
@@ -3999,7 +4053,7 @@
const newTab = document.getElementById('login-vnc-newtab'); const newTab = document.getElementById('login-vnc-newtab');
if (info && info.vncEnabled) { if (info && info.vncEnabled) {
const url = buildNovncUrl(info.novncPort); const url = buildNovncUrl(info);
if (frame.src !== url) frame.src = url; if (frame.src !== url) frame.src = url;
newTab.href = url; newTab.href = url;
wrap.style.display = ''; wrap.style.display = '';
@@ -4031,11 +4085,32 @@
} }
} }
async function updateRedditSessionBanner(data) {
const recoveryBanner = document.getElementById('reddit-session-banner');
const recoveryTitle = document.getElementById('reddit-session-banner-title');
const recoveryDetail = document.getElementById('reddit-session-banner-detail');
if (data.status === 'unreachable') {
recoveryBanner.hidden = false;
recoveryBanner.classList.add('unreachable');
recoveryTitle.textContent = 'Reddit is unreachable from this server';
recoveryDetail.textContent = 'Chat sync is paused. The remote login may not load until server connectivity recovers.';
} else if (data.status === 'login-required') {
recoveryBanner.hidden = false;
recoveryBanner.classList.remove('unreachable');
recoveryTitle.textContent = 'Reddit session expired';
recoveryDetail.textContent = 'Chat sync is paused. Open the remote browser and log in to reconnect.';
} else {
recoveryBanner.hidden = true;
recoveryBanner.classList.remove('unreachable');
}
}
async function updateLoginButtonVisibility() { async function updateLoginButtonVisibility() {
try { try {
const res = await fetch('/api/login/status'); const res = await fetch('/api/login/status');
const data = await res.json(); const data = await res.json();
document.getElementById('login-btn').style.display = data.loggedIn ? 'none' : ''; document.getElementById('login-btn').style.display = data.loggedIn ? 'none' : '';
await updateRedditSessionBanner(data);
} catch (err) { } catch (err) {
// If the endpoint isn't reachable yet, leave the button hidden. // If the endpoint isn't reachable yet, leave the button hidden.
} }
+27 -1
View File
@@ -4,10 +4,24 @@ import type { Logger } from '../utils/logger.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
export type RedditSessionState =
| 'unknown'
| 'authenticated'
| 'login-required'
| 'unreachable';
export function classifyRedditSessionError(error: unknown): Exclude<RedditSessionState, 'unknown' | 'authenticated'> {
const message = error instanceof Error ? error.message : String(error);
return /ERR_(?:HTTP_RESPONSE_CODE_FAILURE|CONNECTION|TIMED_OUT|NAME_NOT_RESOLVED|NETWORK_CHANGED)|net::|ENOTFOUND|ECONN(?:REFUSED|RESET)|fetch failed/i.test(message)
? 'unreachable'
: 'login-required';
}
export class BrowserClient { export class BrowserClient {
private context: BrowserContext | null = null; private context: BrowserContext | null = null;
private sessionDir: string | null = null; private sessionDir: string | null = null;
private sessionValidityInFlight: Promise<boolean> | null = null; private sessionValidityInFlight: Promise<boolean> | null = null;
private sessionState: RedditSessionState = 'unknown';
constructor( constructor(
private config: Config, private config: Config,
@@ -51,6 +65,10 @@ export class BrowserClient {
return this.context; return this.context;
} }
getSessionState(): RedditSessionState {
return this.sessionState;
}
async close(): Promise<void> { async close(): Promise<void> {
if (this.context) { if (this.context) {
this.logger.info('Closing browser'); this.logger.info('Closing browser');
@@ -85,6 +103,7 @@ export class BrowserClient {
for (const selector of visibleIndicators) { for (const selector of visibleIndicators) {
const el = page.locator(selector).first(); const el = page.locator(selector).first();
if (await el.isVisible().catch(() => false)) { if (await el.isVisible().catch(() => false)) {
this.sessionState = 'authenticated';
return true; return true;
} }
} }
@@ -97,13 +116,17 @@ export class BrowserClient {
for (const selector of attributeIndicators) { for (const selector of attributeIndicators) {
const el = await page.$(selector); const el = await page.$(selector);
if (el) { if (el) {
this.sessionState = 'authenticated';
return true; return true;
} }
} }
// Fallback to a direct auth check; this avoids false negatives from hidden login buttons. // Fallback to a direct auth check; this avoids false negatives from hidden login buttons.
return await this.isLoggedInViaApi(page); const loggedIn = await this.isLoggedInViaApi(page);
this.sessionState = loggedIn ? 'authenticated' : 'login-required';
return loggedIn;
} catch (error) { } catch (error) {
this.sessionState = classifyRedditSessionError(error);
this.logger.error('Failed to check session validity:', error); this.logger.error('Failed to check session validity:', error);
return false; return false;
} }
@@ -128,6 +151,7 @@ export class BrowserClient {
async login(): Promise<boolean> { async login(): Promise<boolean> {
if (!this.config.reddit.username || !this.config.reddit.password) { if (!this.config.reddit.username || !this.config.reddit.password) {
this.sessionState = 'login-required';
this.logger.error('Login failed: Missing credentials in config/env'); this.logger.error('Login failed: Missing credentials in config/env');
return false; return false;
} }
@@ -202,6 +226,7 @@ export class BrowserClient {
const isValid = await this.isSessionValid(); const isValid = await this.isSessionValid();
if (isValid) { if (isValid) {
this.sessionState = 'authenticated';
this.logger.info('Login successful!'); this.logger.info('Login successful!');
return true; return true;
} }
@@ -236,6 +261,7 @@ export class BrowserClient {
return false; return false;
} catch (error) { } catch (error) {
this.sessionState = classifyRedditSessionError(error);
this.logger.error('Error during automated login:', error); this.logger.error('Error during automated login:', error);
return false; return false;
} }
+2 -1
View File
@@ -11,6 +11,7 @@ export function createLoginRouter(ctx: WebContext): Router {
res.json({ res.json({
vncEnabled: Boolean(process.env.DISPLAY), vncEnabled: Boolean(process.env.DISPLAY),
novncPort, novncPort,
publicUrl: process.env.NOVNC_PUBLIC_URL || null,
passwordRequired: Boolean(process.env.VNC_PASSWORD), passwordRequired: Boolean(process.env.VNC_PASSWORD),
}); });
}); });
@@ -23,7 +24,7 @@ export function createLoginRouter(ctx: WebContext): Router {
} }
try { try {
const loggedIn = await browser.isSessionValid(); const loggedIn = await browser.isSessionValid();
res.json({ ready: true, loggedIn }); res.json({ ready: true, loggedIn, status: browser.getSessionState() });
} catch (error) { } catch (error) {
ctx.logger.error('Failed to check login status:', error); ctx.logger.error('Failed to check login status:', error);
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
+16
View File
@@ -0,0 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('dashboard Reddit session banner', () => {
it('renders a persistent recovery banner with a Reddit login action', () => {
const html = fs.readFileSync(path.resolve('public/index.html'), 'utf8');
expect(html).toContain('id="reddit-session-banner"');
expect(html).toContain('id="reddit-session-banner-title"');
expect(html).toContain('Open Reddit Login');
expect(html).toContain("data.status === 'unreachable'");
expect(html).toContain("data.status === 'login-required'");
expect(html).toContain('info.publicUrl');
});
});
+81
View File
@@ -0,0 +1,81 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { classifyRedditSessionError } from '../src/browser/client.js';
import { createLoginRouter } from '../src/web/routes/login.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
}
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
),
);
delete process.env.NOVNC_PUBLIC_URL;
});
function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable') {
const app = express();
app.use(
createLoginRouter({
services: {
browser: {
isSessionValid: async () => state === 'authenticated',
getSessionState: () => state,
},
},
logger: { error: () => undefined },
} as never),
);
return app;
}
describe('Reddit session status', () => {
it('reports when Reddit is unreachable instead of presenting it as logged out', async () => {
const response = await request(appWithBrowser('unreachable'), '/login/status');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
ready: true,
loggedIn: false,
status: 'unreachable',
});
});
it('reports when an interactive Reddit login is required', async () => {
const response = await request(appWithBrowser('login-required'), '/login/status');
expect(await response.json()).toMatchObject({
loggedIn: false,
status: 'login-required',
});
});
it('returns the configured public noVNC URL', async () => {
process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk';
const response = await request(appWithBrowser('authenticated'), '/login/info');
expect(await response.json()).toMatchObject({
publicUrl: 'https://red-vnc.apps.jbrunton.co.uk',
});
});
it('classifies Reddit navigation failures as unreachable', () => {
expect(
classifyRedditSessionError(
new Error('page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at https://www.reddit.com/'),
),
).toBe('unreachable');
expect(classifyRedditSessionError(new Error('login form was not found'))).toBe('login-required');
});
});