feat(dashboard): complete React cutover

This commit is contained in:
jjbrunton committed 2026-08-08 16:11:57 +01:00
1 parent d5f0175dc2
commit ec0cd1318b
13 files changed
+50 -4291

No files matched your search

+3 -5
View File
@@ -1000,16 +1000,14 @@ the complete validated event envelope:
Clients may reconnect with `Last-Event-ID`; the server replays retained events Clients may reconnect with `Last-Event-ID`; the server replays retained events
after that ID in order. The stream sends heartbeats, bounds buffered events and after that ID in order. The stream sends heartbeats, bounds buffered events and
slow-client queues. Applying the same authentication boundary as the API is a slow-client queues, and uses the same authentication boundary as the API.
pending cutover gate.
Current event families include bot status, message sent or failed, Current event families include bot status, message sent or failed,
conversation updated, reply received, sync lifecycle and progress, rate-limit conversation updated, reply received, sync lifecycle and progress, rate-limit
state, Reddit session state, and logs. state, Reddit session state, and logs.
`GET /api/logs/stream` remains temporarily available for the legacy dashboard. `GET /api/logs/stream` remains temporarily available for compatibility tooling.
Its untyped `connected`, `log`, and `sync-progress` frames should not be used by New clients use the typed `/api/events/stream` endpoint.
new clients.
**Log Levels**: **Log Levels**:
+5 -15
View File
@@ -167,8 +167,6 @@ RedditBot/
│ └── dist/ # Generated production assets (not committed) │ └── dist/ # Generated production assets (not committed)
├── packages/ ├── packages/
│ └── api-contracts/ # Shared Zod HTTP and realtime schemas │ └── api-contracts/ # Shared Zod HTTP and realtime schemas
├── public/
│ └── index.html # Temporary legacy dashboard fallback
├── scripts/ ├── scripts/
│ ├── inspect-chat-dom.ts # Debug: inspect chat DOM structure │ ├── inspect-chat-dom.ts # Debug: inspect chat DOM structure
│ └── login.ts # Manual browser login helper │ └── login.ts # Manual browser login helper
@@ -203,7 +201,7 @@ RedditBot/
│ └── image.ts # Image download + base64 + MIME validation (34 lines) │ └── image.ts # Image download + base64 + MIME validation (34 lines)
└── web/ └── web/
├── server.ts # Express API, SSE, and dashboard host ├── server.ts # Express API, SSE, and dashboard host
├── dashboard-assets.ts # React-build selection with legacy fallback ├── dashboard-assets.ts # Required production React-build resolver
└── events/ # Typed event hub and Express SSE adapter └── events/ # Typed event hub and Express SSE adapter
``` ```
@@ -412,10 +410,8 @@ Express server with API route modules and SSE transport.
keys; cached content remains visible if the stream reconnects. keys; cached content remains visible if the stream reconnects.
- Vite writes production assets to `apps/dashboard/dist`. - Vite writes production assets to `apps/dashboard/dist`.
The asset resolver prefers the React build and can fall back to The asset resolver requires the production React build. Express serves those
`public/index.html`. Wiring that resolver into authenticated Express static and assets, API/SSE routes, and private media behind one authentication boundary.
private-media hosting is still a migration gate; the legacy file has not been
removed.
--- ---
@@ -922,15 +918,9 @@ Deployment documentation is provided in `DOKPLOY_DEPLOY.md`.
### Medium Priority ### Medium Priority
7. **Legacy dashboard fallback** - `public/index.html` remains during migration. 7. **Config Mutability** - Runtime config updates via `PUT /api/config` modify the in-memory config object. Consider a proper config service with validation on updates.
Remove it only after authenticated React hosting and feature-parity gates pass.
8. **Settings route** - The React route is still a placeholder; runtime and 8. **Error Handling** - Some services have basic try/catch. Consider structured error types with retry logic.
access settings have not yet moved into validated route-backed forms.
9. **Config Mutability** - Runtime config updates via `PUT /api/config` modify the in-memory config object. Consider a proper config service with validation on updates.
10. **Error Handling** - Some services have basic try/catch. Consider structured error types with retry logic.
### Low Priority ### Low Priority
+4 -5
View File
@@ -691,11 +691,10 @@ services/repositories -> Express /api snapshots -> Zod -> TanStack Query -> Reac
+-> realtime event hub -> /api/events/stream --------+ +-> realtime event hub -> /api/events/stream --------+
``` ```
### Legacy fallback model ### Retired legacy model
The diagram below describes `public/index.html`, which remains only as a The historical diagram below is retained for migration context only.
compatibility fallback until Express is switched to authenticated React static `public/index.html` has been removed and is no longer served.
hosting and private-media protection is verified.
``` ```
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
@@ -734,7 +733,7 @@ hosting and private-media protection is verified.
│ │ └─► Data: { level, message, timestamp } │ │ │ └─► Data: { level, message, timestamp } │
│ └─► On disconnect: unsubscribe │ │ └─► On disconnect: unsubscribe │
│ │ │ │
│ ──── LEGACY FALLBACK (public/index.html) ──────────────────── │ │ ──── RETIRED LEGACY IMPLEMENTATION ────────────────────────── │
│ │ │ │
│ JavaScript polls API endpoints: │ │ JavaScript polls API endpoints: │
│ ├─► /api/stats every 5 seconds │ │ ├─► /api/stats every 5 seconds │
+1 -1
View File
@@ -124,7 +124,7 @@ npm start
### 5. Access Dashboard ### 5. Access Dashboard
Open the Vite URL (normally http://localhost:5173) during frontend development. Open the Vite URL (normally http://localhost:5173) during frontend development.
Production and legacy-fallback checks use http://localhost:3000. Production dashboard checks use http://localhost:3000.
--- ---
+2 -5
View File
@@ -30,8 +30,6 @@ RedditBot/
│ └── dashboard/ # React + Vite operator console │ └── dashboard/ # React + Vite operator console
├── packages/ ├── packages/
│ └── api-contracts/ # Shared Zod transport contracts │ └── api-contracts/ # Shared Zod transport contracts
├── public/
│ └── index.html # Temporary legacy dashboard fallback
├── scripts/ ├── scripts/
│ ├── login.ts # Manual login helper │ ├── login.ts # Manual login helper
│ └── inspect-chat-dom.ts # DOM inspection debug script │ └── inspect-chat-dom.ts # DOM inspection debug script
@@ -521,9 +519,8 @@ Reddit frequently updates their `faceplate-*` components. Shadow DOM traversal c
### Dashboard Migration Boundary ### Dashboard Migration Boundary
The React feature slices live in `apps/dashboard`, but `public/index.html` The React application lives in `apps/dashboard`. A production build is required
remains as a compatibility fallback. Do not delete it until authenticated before the Express server starts; there is no legacy HTML fallback.
Express hosting, private-media protection, and browser parity checks are proven.
The repository has automated service, contract, component, and transport tests. The repository has automated service, contract, component, and transport tests.
Manual browser verification is still required for Reddit DOM changes and the Manual browser verification is still required for Reddit DOM changes and the
+18 -26
View File
@@ -2,29 +2,22 @@
## Outcome ## Outcome
Replace the inline dashboard in `public/index.html` with a typed, accessible React The inline dashboard has been replaced by a typed, accessible React application.
application without disrupting the bot worker or its Express API. At cutover, Express remains the authenticated API, SSE, private-media, and production
Express will remain the authenticated API, SSE, private-media, and static-asset static-asset host.
host.
## Current status ## Current status
The React application and production build are present. The current routes are The current routes are Overview, Conversations (including route-addressable
Overview, Conversations (including route-addressable detail), Media, detail), Media, Performance, Operations, and validated Settings and Access.
Performance, Operations, and a Settings placeholder. Shared HTTP and realtime Shared HTTP and realtime schemas live in `packages/api-contracts`, and
schemas live in `packages/api-contracts`, and `/api/events/stream` supplies the `/api/events/stream` supplies the typed SSE transport.
typed SSE transport.
`npm run build` compiles the server and writes the Vite application to `npm run build` compiles the server and writes the Vite application to
`apps/dashboard/dist`. `src/web/dashboard-assets.ts` can select that build and `apps/dashboard/dist`. `src/web/dashboard-assets.ts` requires that build and
fall back to `public/index.html`, but the checked-in Express server has not yet fails startup with an actionable error if it is absent. The legacy HTML has
completed that authenticated static-hosting cutover. Until it does, the legacy been removed. Root pages, assets, APIs, SSE, and private media share the same
page remains the served fallback and the dashboard must not be exposed to an timing-safe dashboard authentication boundary.
untrusted network.
The migration is complete only when the legacy dashboard can be removed without
losing a documented capability and the full test, type-check, production-build,
browser-acceptance, authentication, private-media, and reconnect gates pass.
## Architecture ## Architecture
@@ -50,7 +43,7 @@ The application shell provides a responsive sidebar or drawer with these routes:
| `/media` | Private blurred-by-default gallery and sync controls | | `/media` | Private blurred-by-default gallery and sync controls |
| `/performance` | Trends, strategies, subreddits, and successful examples | | `/performance` | Trends, strategies, subreddits, and successful examples |
| `/operations` | Domain activity, background jobs, and diagnostics | | `/operations` | Domain activity, background jobs, and diagnostics |
| `/settings` | Placeholder for runtime and access settings | | `/settings` | Validated runtime configuration and Reddit access state |
Overview surfaces bot state, dry-run state, cooldowns, timers, and the guarded Overview surfaces bot state, dry-run state, cooldowns, timers, and the guarded
start/stop control. Operations owns connection health, sync state, activity, start/stop control. Operations owns connection health, sync state, activity,
@@ -89,8 +82,8 @@ and falls back to visibility-aware polling where necessary.
9. Run feature parity and security audits, switch Express to the Vite output, 9. Run feature parity and security audits, switch Express to the Vite output,
remove the legacy HTML, and update operator and deployment documentation. remove the legacy HTML, and update operator and deployment documentation.
Each slice must be usable independently and must not require a flag-day rewrite. All slices now run from the React application; the migration phases above are
The legacy dashboard remains available until replacement behavior is proven. retained as implementation history.
## Engineering gates ## Engineering gates
@@ -108,12 +101,11 @@ The legacy dashboard remains available until replacement behavior is proven.
- No production frontend or backend code uses `any` or `console.log`. - No production frontend or backend code uses `any` or `console.log`.
- Mutations display the confirmed server outcome and do not rely on optimistic - Mutations display the confirmed server outcome and do not rely on optimistic
success for operational controls. success for operational controls.
- Production builds are deterministic. Serving only generated frontend assets - Production builds are deterministic. The server serves only generated React
plus authenticated private content remains a cutover gate. assets plus authenticated private content.
## Cutover evidence ## Cutover evidence
Before deleting `public/index.html`, record evidence for every legacy capability, Cutover verification covers API and SSE authentication, private media access,
all API and SSE authentication paths, private media access, container restart and
SSE recovery, responsive navigation, frontend type-check/build, backend build, SSE recovery, responsive navigation, frontend type-check/build, backend build,
the complete Vitest suite, and the critical Playwright journeys. the complete Vitest suite, and browser smoke journeys.
-4195
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -55,7 +55,7 @@ export function createDashboardSessionRouter(token: string): Router {
router.get('/auth/login', (_req, res) => { router.get('/auth/login', (_req, res) => {
res res
.set('Cache-Control', 'no-cache, no-store, must-revalidate') .set('Cache-Control', 'no-cache, no-store, must-revalidate')
.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'") .set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'")
.type('html') .type('html')
.send(`<!doctype html><title>Dashboard sign in</title><form id="login"><label>Dashboard token <input name="token" type="password" required autofocus></label><button>Sign in</button></form><script>document.getElementById('login').addEventListener('submit',async(e)=>{e.preventDefault();const r=await fetch('/auth/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:new FormData(e.target).get('token')})});if(r.ok)location='/';else alert('Invalid dashboard token');});</script>`); .send(`<!doctype html><title>Dashboard sign in</title><form id="login"><label>Dashboard token <input name="token" type="password" required autofocus></label><button>Sign in</button></form><script>document.getElementById('login').addEventListener('submit',async(e)=>{e.preventDefault();const r=await fetch('/auth/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:new FormData(e.target).get('token')})});if(r.ok)location='/';else alert('Invalid dashboard token');});</script>`);
}); });
+3 -9
View File
@@ -4,7 +4,6 @@ import path from 'node:path';
export interface DashboardAssets { export interface DashboardAssets {
directory: string; directory: string;
indexFile: string; indexFile: string;
isLegacy: boolean;
} }
export type FileExists = (filePath: string) => boolean; export type FileExists = (filePath: string) => boolean;
@@ -20,15 +19,10 @@ export function resolveDashboardAssets(
return { return {
directory: reactDirectory, directory: reactDirectory,
indexFile: reactIndexFile, indexFile: reactIndexFile,
isLegacy: false,
}; };
} }
const legacyDirectory = path.join(workspaceDirectory, 'public'); throw new Error(
`React dashboard build is missing at ${reactIndexFile}. Run npm run dashboard:build before starting the web server.`,
return { );
directory: legacyDirectory,
indexFile: path.join(legacyDirectory, 'index.html'),
isLegacy: true,
};
} }
+4 -7
View File
@@ -11,17 +11,14 @@ describe('resolveDashboardAssets', () => {
expect(resolveDashboardAssets(workspace, fileExists)).toEqual({ expect(resolveDashboardAssets(workspace, fileExists)).toEqual({
directory: path.dirname(reactIndex), directory: path.dirname(reactIndex),
indexFile: reactIndex, indexFile: reactIndex,
isLegacy: false,
}); });
}); });
it('falls back to the legacy dashboard during incremental migration', () => { it('fails clearly when the production React build is missing', () => {
const workspace = path.resolve('workspace'); const workspace = path.resolve('workspace');
expect(resolveDashboardAssets(workspace, () => false)).toEqual({ expect(() => resolveDashboardAssets(workspace, () => false)).toThrow(
directory: path.join(workspace, 'public'), 'React dashboard build is missing',
indexFile: path.join(workspace, 'public', 'index.html'), );
isLegacy: true,
});
}); });
}); });
-16
View File
@@ -1,16 +0,0 @@
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');
});
});
-6
View File
@@ -110,10 +110,4 @@ describe('authenticated React dashboard cutover', () => {
expect(upload.status).toBe(404); expect(upload.status).toBe(404);
}); });
it('uses the legacy dashboard only when a React build is absent', async () => {
const running = await startServer({ includeReactBuild: false });
runningServers.push(running);
const response = await fetch(running.baseUrl, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } });
expect(await response.text()).toContain('Legacy dashboard');
});
}); });
+9
View File
@@ -70,6 +70,15 @@ describe('dashboard authentication', () => {
expect(valid.headers.get('set-cookie')).toMatch(/SameSite=Strict/); expect(valid.headers.get('set-cookie')).toMatch(/SameSite=Strict/);
}); });
it('allows the login page to create a same-origin session under its CSP', async () => {
const app = express();
app.use(createDashboardSessionRouter('dashboard-secret'));
const response = await request(app, '/auth/login');
expect(response.headers.get('content-security-policy')).toContain("connect-src 'self'");
});
it('does not mount debug endpoints unless explicitly enabled', async () => { it('does not mount debug endpoints unless explicitly enabled', async () => {
const config = ConfigSchema.parse({ const config = ConfigSchema.parse({
reddit: { subreddits: ['test'] }, reddit: { subreddits: ['test'] },