feat(dashboard): complete React cutover
This commit is contained in:
1 parent
d5f0175dc2
commit
ec0cd1318b
13 files changed
+50
-4291
No files matched your search
+3
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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>`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -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'] },
|
||||||
|
|||||||
Reference in new issue
Block a user