feat(dashboard): align contracts and production build

This commit is contained in:
jjbrunton committed 2026-08-08 14:58:27 +01:00
1 parent 69d560d5c5
commit d6d29badcd
9 files changed
+189 -48

No files matched your search

-1
View File
@@ -3,7 +3,6 @@ node_modules/
# Build output # Build output
dist/ dist/
dist-dashboard/
# Environment variables # Environment variables
.env .env
+10 -45
View File
@@ -1,46 +1,11 @@
import { z } from 'zod'; export {
ControlResponseSchema as controlResponseSchema,
StatsResponseSchema as botStatsSchema,
TimersResponseSchema as timersSchema,
} from '../../../../packages/api-contracts/src/index.js';
const activityItemSchema = z.object({ export type {
timestamp: z.number(), ControlResponse,
type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']), StatsResponse as BotStats,
message: z.string(), TimersResponse as Timers,
recipient: z.string().optional(), } from '../../../../packages/api-contracts/src/index.js';
imageUrl: z.string().optional(),
});
export const botStatsSchema = z.object({
isRunning: z.boolean(),
dryRunMode: z.boolean(),
totalProcessed: z.number(),
totalMessagesSent: z.number(),
totalResponses: z.number(),
responseRate: z.number(),
messagesThisHour: z.number(),
maxMessagesPerHour: z.number(),
subreddits: z.array(z.object({ name: z.string(), type: z.enum(['image', 'chat']) })),
targetGenders: z.array(z.string()),
recentActivity: z.array(activityItemSchema),
invitationCooldown: z
.object({
until: z.number(),
reason: z.string(),
redditCode: z.string().nullable(),
})
.nullable(),
});
export const timersSchema = z.object({
nextMessageAvailableAt: z.number().nullable(),
hourlyResetAt: z.number(),
isHourlyLimitReached: z.boolean(),
messagesRemaining: z.number(),
});
export const controlResponseSchema = z.object({
success: z.literal(true),
running: z.boolean(),
});
export type BotStats = z.infer<typeof botStatsSchema>;
export type Timers = z.infer<typeof timersSchema>;
export type ControlResponse = z.infer<typeof controlResponseSchema>;
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)), root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react()], plugins: [react()],
build: { build: {
outDir: '../../dist-dashboard', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
}, },
server: { server: {
+102
View File
@@ -0,0 +1,102 @@
# React Dashboard Migration
## Outcome
Replace the inline dashboard in `public/index.html` with a typed, accessible React
application without disrupting the bot worker or its Express API. Express remains
the authenticated API, SSE, private-media, and static-asset host.
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
- `apps/dashboard`: React, TypeScript, Vite, React Router, and TanStack Query.
- `packages/api-contracts`: shared Zod schemas for HTTP and realtime transport.
- `src/web`: existing Express host, gradually adapted to the shared contracts.
- Backend service and database types stay private to the backend. Transport
contracts must not expose database rows as an accidental public API.
- Server state lives in TanStack Query. Route, filter, and selection state lives
in URLs. Local component state is reserved for ephemeral presentation state.
- Do not add a global client-state library until a demonstrated cross-feature
state requirement cannot be met by those boundaries.
## Navigation
The application shell provides a responsive sidebar or drawer with these routes:
| Route | Responsibility |
| --- | --- |
| `/` | Health, metrics, timers, limits, and important recent activity |
| `/conversations` | Searchable conversation inbox |
| `/conversations/:id` | Conversation history, context, suggestions, and sending |
| `/media` | Private blurred-by-default gallery and sync controls |
| `/performance` | Trends, strategies, subreddits, and successful examples |
| `/operations` | Domain activity, background jobs, and diagnostics |
| `/operations/logs` | Filterable bounded realtime technical console |
| `/settings/*` | Monitoring, limits, behaviour, AI, and access settings |
The shell always surfaces bot state, dry-run state, Reddit session state,
realtime connection state, cooldowns, active sync progress, notifications, and
the guarded start/stop control.
## Realtime model
HTTP snapshots are authoritative. A typed SSE event stream updates or invalidates
the relevant query cache. Every event has an ID, type, occurrence timestamp, and
validated payload. The stream supports bounded replay and `Last-Event-ID` resume.
After reconnection the client refetches authoritative snapshots to repair gaps.
Required event families are bot status, message sent or failed, conversation
updated, reply received, sync lifecycle and progress, rate-limit state, Reddit
session state, and technical logs.
Important domain events appear in a readable activity timeline. Raw logs remain
in Operations. Toasts are reserved for user-triggered outcomes requiring timely
feedback. Realtime disconnection never blanks cached content; it marks it stale
and falls back to visibility-aware polling where necessary.
## Feature slices
1. Capture and validate existing HTTP contracts and critical legacy journeys.
2. Add the frontend build, application shell, authentication handling, routing,
error boundaries, design tokens, query client, and test harness.
3. Deliver Overview as the first production vertical slice.
4. Add the typed realtime event core, SSE transport, reconnection, and query
invalidation.
5. Migrate Conversations, including explicit suggestion and send mutations.
6. Migrate Media, preserving authentication and blur-by-default privacy.
7. Migrate Performance with sample sizes and low-confidence warnings.
8. Migrate Operations, access/login, and route-backed validated Settings.
9. Run feature parity and security audits, switch Express to the Vite output,
remove the legacy HTML, and update operator and deployment documentation.
Each slice must be usable independently and must not require a flag-day rewrite.
The legacy dashboard remains available until replacement behavior is proven.
## Engineering gates
- New behavior follows red-green-refactor with Vitest.
- Component tests use Testing Library and test observable behavior.
- HTTP tests use a controlled boundary such as MSW rather than mocking `fetch`
throughout feature tests.
- Contract tests prove representative current responses parse and malformed
payloads fail.
- Realtime tests cover ordering, replay, duplicate handling, reconnection, and
cache repair.
- Browser tests cover authentication, start/stop, conversation inspection,
sending confirmation, sync, settings persistence, and private media.
- Interactive controls are keyboard accessible and restore focus after dialogs.
- No production frontend or backend code uses `any` or `console.log`.
- Mutations display the confirmed server outcome and do not rely on optimistic
success for operational controls.
- Production builds are deterministic and the Docker image serves only generated
frontend assets plus authenticated private content.
## Cutover evidence
Before deleting `public/index.html`, record evidence for every legacy capability,
all API and SSE authentication paths, private media access, container restart and
SSE recovery, responsive navigation, frontend type-check/build, backend build,
the complete Vitest suite, and the critical Playwright journeys.
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "npm run build:server && npm run dashboard:build",
"build:server": "tsc",
"dashboard:build": "vite build --config apps/dashboard/vite.config.ts", "dashboard:build": "vite build --config apps/dashboard/vite.config.ts",
"dashboard:dev": "vite --config apps/dashboard/vite.config.ts", "dashboard:dev": "vite --config apps/dashboard/vite.config.ts",
"dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit", "dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit",
+5
View File
@@ -6,6 +6,10 @@ export const ErrorResponseSchema = z.strictObject({
}); });
export const SuccessResponseSchema = z.strictObject({ success: z.literal(true) }); export const SuccessResponseSchema = z.strictObject({ success: z.literal(true) });
export const ControlResponseSchema = z.strictObject({
success: z.literal(true),
running: z.boolean(),
});
export const RejectedActionResponseSchema = z.strictObject({ export const RejectedActionResponseSchema = z.strictObject({
success: z.literal(false), success: z.literal(false),
reason: z.string(), reason: z.string(),
@@ -13,3 +17,4 @@ export const RejectedActionResponseSchema = z.strictObject({
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>; export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>; export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
export type ControlResponse = z.infer<typeof ControlResponseSchema>;
@@ -6,6 +6,7 @@ import {
ChatSyncTriggerResponseSchema, ChatSyncTriggerResponseSchema,
ConfigResponseSchema, ConfigResponseSchema,
ConfigUpdateRequestSchema, ConfigUpdateRequestSchema,
ControlResponseSchema,
ConversationDetailResponseSchema, ConversationDetailResponseSchema,
ConversationsResponseSchema, ConversationsResponseSchema,
DashboardEventSchema, DashboardEventSchema,
@@ -62,6 +63,13 @@ describe('dashboard endpoint contracts', () => {
expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false); expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false);
}); });
it('validates authoritative start and stop responses', () => {
expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(true);
expect(ControlResponseSchema.safeParse({ success: true, running: false }).success).toBe(true);
expect(ControlResponseSchema.safeParse({ success: true }).success).toBe(false);
expect(ControlResponseSchema.safeParse({ success: false, running: true }).success).toBe(false);
});
it('validates timer, activity, message, and successful-example payloads', () => { it('validates timer, activity, message, and successful-example payloads', () => {
expect(TimersResponseSchema.safeParse({ expect(TimersResponseSchema.safeParse({
nextMessageAvailableAt: null, nextMessageAvailableAt: null,
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs';
import path from 'node:path';
export interface DashboardAssets {
directory: string;
indexFile: string;
isLegacy: boolean;
}
export type FileExists = (filePath: string) => boolean;
export function resolveDashboardAssets(
workspaceDirectory: string,
fileExists: FileExists = fs.existsSync,
): DashboardAssets {
const reactDirectory = path.join(workspaceDirectory, 'apps', 'dashboard', 'dist');
const reactIndexFile = path.join(reactDirectory, 'index.html');
if (fileExists(reactIndexFile)) {
return {
directory: reactDirectory,
indexFile: reactIndexFile,
isLegacy: false,
};
}
const legacyDirectory = path.join(workspaceDirectory, 'public');
return {
directory: legacyDirectory,
indexFile: path.join(legacyDirectory, 'index.html'),
isLegacy: true,
};
}
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { resolveDashboardAssets } from '../src/web/dashboard-assets.js';
describe('resolveDashboardAssets', () => {
it('selects the built React dashboard when its index exists', () => {
const workspace = path.resolve('workspace');
const reactIndex = path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html');
const fileExists = vi.fn((candidate: string) => candidate === reactIndex);
expect(resolveDashboardAssets(workspace, fileExists)).toEqual({
directory: path.dirname(reactIndex),
indexFile: reactIndex,
isLegacy: false,
});
});
it('falls back to the legacy dashboard during incremental migration', () => {
const workspace = path.resolve('workspace');
expect(resolveDashboardAssets(workspace, () => false)).toEqual({
directory: path.join(workspace, 'public'),
indexFile: path.join(workspace, 'public', 'index.html'),
isLegacy: true,
});
});
});