feat(dashboard): align contracts and production build
This commit is contained in:
1 parent
69d560d5c5
commit
d6d29badcd
9 files changed
+189
-48
No files matched your search
@@ -3,7 +3,6 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-dashboard/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
@@ -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({
|
||||
timestamp: z.number(),
|
||||
type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']),
|
||||
message: z.string(),
|
||||
recipient: z.string().optional(),
|
||||
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>;
|
||||
export type {
|
||||
ControlResponse,
|
||||
StatsResponse as BotStats,
|
||||
TimersResponse as Timers,
|
||||
} from '../../../../packages/api-contracts/src/index.js';
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../../dist-dashboard',
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -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
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"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:dev": "vite --config apps/dashboard/vite.config.ts",
|
||||
"dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit",
|
||||
|
||||
@@ -6,6 +6,10 @@ export const ErrorResponseSchema = z.strictObject({
|
||||
});
|
||||
|
||||
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({
|
||||
success: z.literal(false),
|
||||
reason: z.string(),
|
||||
@@ -13,3 +17,4 @@ export const RejectedActionResponseSchema = z.strictObject({
|
||||
|
||||
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
||||
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
|
||||
export type ControlResponse = z.infer<typeof ControlResponseSchema>;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChatSyncTriggerResponseSchema,
|
||||
ConfigResponseSchema,
|
||||
ConfigUpdateRequestSchema,
|
||||
ControlResponseSchema,
|
||||
ConversationDetailResponseSchema,
|
||||
ConversationsResponseSchema,
|
||||
DashboardEventSchema,
|
||||
@@ -62,6 +63,13 @@ describe('dashboard endpoint contracts', () => {
|
||||
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', () => {
|
||||
expect(TimersResponseSchema.safeParse({
|
||||
nextMessageAvailableAt: null,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user