Show Reddit recovery status in dashboard
This commit is contained in:
1 parent
d2b3a1b31d
commit
316dc3dd3e
6 files changed
+208
-5
No files matched your search
@@ -22,12 +22,16 @@ services:
|
||||
- "6080" # noVNC for human login
|
||||
environment:
|
||||
- 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_PASSWORD=${REDDIT_PASSWORD}
|
||||
# Chromium runs headed under Xvfb so a human can complete login over noVNC.
|
||||
- BROWSER_HEADLESS=false
|
||||
# Required to lock down the VNC endpoint.
|
||||
- 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:
|
||||
reddit_bot_data:
|
||||
+78
-3
@@ -193,6 +193,47 @@
|
||||
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-badge {
|
||||
padding: 6px 14px;
|
||||
@@ -2288,6 +2329,14 @@
|
||||
|
||||
<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;">
|
||||
<strong>⏸️ Chat throttle active.</strong>
|
||||
<span id="cooldown-reason"></span>
|
||||
@@ -3957,9 +4006,13 @@
|
||||
|
||||
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:';
|
||||
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() {
|
||||
@@ -3986,6 +4039,7 @@
|
||||
} 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>.';
|
||||
}
|
||||
updateRedditSessionBanner(data);
|
||||
} catch (err) {
|
||||
banner.textContent = 'Could not check session status.';
|
||||
}
|
||||
@@ -3999,7 +4053,7 @@
|
||||
const newTab = document.getElementById('login-vnc-newtab');
|
||||
|
||||
if (info && info.vncEnabled) {
|
||||
const url = buildNovncUrl(info.novncPort);
|
||||
const url = buildNovncUrl(info);
|
||||
if (frame.src !== url) frame.src = url;
|
||||
newTab.href = url;
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch('/api/login/status');
|
||||
const data = await res.json();
|
||||
document.getElementById('login-btn').style.display = data.loggedIn ? 'none' : '';
|
||||
await updateRedditSessionBanner(data);
|
||||
} catch (err) {
|
||||
// If the endpoint isn't reachable yet, leave the button hidden.
|
||||
}
|
||||
|
||||
+27
-1
@@ -4,10 +4,24 @@ import type { Logger } from '../utils/logger.js';
|
||||
import path from 'path';
|
||||
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 {
|
||||
private context: BrowserContext | null = null;
|
||||
private sessionDir: string | null = null;
|
||||
private sessionValidityInFlight: Promise<boolean> | null = null;
|
||||
private sessionState: RedditSessionState = 'unknown';
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
@@ -51,6 +65,10 @@ export class BrowserClient {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
getSessionState(): RedditSessionState {
|
||||
return this.sessionState;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.context) {
|
||||
this.logger.info('Closing browser');
|
||||
@@ -85,6 +103,7 @@ export class BrowserClient {
|
||||
for (const selector of visibleIndicators) {
|
||||
const el = page.locator(selector).first();
|
||||
if (await el.isVisible().catch(() => false)) {
|
||||
this.sessionState = 'authenticated';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -97,13 +116,17 @@ export class BrowserClient {
|
||||
for (const selector of attributeIndicators) {
|
||||
const el = await page.$(selector);
|
||||
if (el) {
|
||||
this.sessionState = 'authenticated';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.sessionState = classifyRedditSessionError(error);
|
||||
this.logger.error('Failed to check session validity:', error);
|
||||
return false;
|
||||
}
|
||||
@@ -128,6 +151,7 @@ export class BrowserClient {
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
if (!this.config.reddit.username || !this.config.reddit.password) {
|
||||
this.sessionState = 'login-required';
|
||||
this.logger.error('Login failed: Missing credentials in config/env');
|
||||
return false;
|
||||
}
|
||||
@@ -202,6 +226,7 @@ export class BrowserClient {
|
||||
|
||||
const isValid = await this.isSessionValid();
|
||||
if (isValid) {
|
||||
this.sessionState = 'authenticated';
|
||||
this.logger.info('Login successful!');
|
||||
return true;
|
||||
}
|
||||
@@ -236,6 +261,7 @@ export class BrowserClient {
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
this.sessionState = classifyRedditSessionError(error);
|
||||
this.logger.error('Error during automated login:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createLoginRouter(ctx: WebContext): Router {
|
||||
res.json({
|
||||
vncEnabled: Boolean(process.env.DISPLAY),
|
||||
novncPort,
|
||||
publicUrl: process.env.NOVNC_PUBLIC_URL || null,
|
||||
passwordRequired: Boolean(process.env.VNC_PASSWORD),
|
||||
});
|
||||
});
|
||||
@@ -23,7 +24,7 @@ export function createLoginRouter(ctx: WebContext): Router {
|
||||
}
|
||||
try {
|
||||
const loggedIn = await browser.isSessionValid();
|
||||
res.json({ ready: true, loggedIn });
|
||||
res.json({ ready: true, loggedIn, status: browser.getSessionState() });
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to check login status:', error);
|
||||
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user