Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51438aa5db | ||
|
|
89f7376b1b | ||
|
|
e003b0d8b2 | ||
|
|
5917b63755 | ||
|
|
20fa83f245 | ||
|
|
768470204b | ||
|
|
8d4eae7ab5 | ||
|
|
40e5f4c818 | ||
|
|
9919b12aab | ||
|
|
d6d29badcd | ||
|
|
69d560d5c5 | ||
|
|
9f4f198800 | ||
|
|
51e3e28a9b | ||
|
|
59c3a61a88 | ||
|
|
939cf408a0 |
No files matched your search
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0b1210" />
|
||||
<title>Reddit Bot Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
botStatsSchema,
|
||||
controlResponseSchema,
|
||||
timersSchema,
|
||||
type BotStats,
|
||||
type ControlResponse,
|
||||
type Timers,
|
||||
} from './contracts.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as ErrorPayload;
|
||||
const message = typeof errorPayload.error === 'string' ? errorPayload.error : `Request failed (${response.status})`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export interface DashboardApi {
|
||||
getStats(): Promise<BotStats>;
|
||||
getTimers(): Promise<Timers>;
|
||||
setRunning(isRunning: boolean): Promise<ControlResponse>;
|
||||
}
|
||||
|
||||
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
|
||||
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(input, init);
|
||||
}
|
||||
|
||||
return {
|
||||
async getStats() {
|
||||
return parseResponse(await request('/api/stats'), botStatsSchema);
|
||||
},
|
||||
async getTimers() {
|
||||
return parseResponse(await request('/api/timers'), timersSchema);
|
||||
},
|
||||
async setRunning(isRunning) {
|
||||
const endpoint = isRunning ? '/api/start' : '/api/stop';
|
||||
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardApi = createDashboardApi();
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
ControlResponseSchema as controlResponseSchema,
|
||||
StatsResponseSchema as botStatsSchema,
|
||||
TimersResponseSchema as timersSchema,
|
||||
} from '../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export type {
|
||||
ControlResponse,
|
||||
StatsResponse as BotStats,
|
||||
TimersResponse as Timers,
|
||||
} from '../../../../packages/api-contracts/src/index.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { dashboardApi } from './client.js';
|
||||
import type { BotStats } from './contracts.js';
|
||||
|
||||
export const dashboardQueryKeys = {
|
||||
stats: ['dashboard', 'stats'] as const,
|
||||
timers: ['dashboard', 'timers'] as const,
|
||||
};
|
||||
|
||||
export function useStatsQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
|
||||
}
|
||||
|
||||
export function useTimersQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers });
|
||||
}
|
||||
|
||||
export function useSetRunningMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: dashboardApi.setRunning,
|
||||
onSuccess(response) {
|
||||
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
|
||||
current ? { ...current, isRunning: response.running } : current,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { navigationItems } from './navigation.js';
|
||||
|
||||
export function AppShell() {
|
||||
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
||||
<div className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">R</span>
|
||||
<div>
|
||||
<strong>Reddit Bot</strong>
|
||||
<span>Operator console</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Primary navigation" id="primary-navigation">
|
||||
{navigationItems.map((item) => (
|
||||
<NavLink
|
||||
aria-label={item.label}
|
||||
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
|
||||
end={item.path === '/'}
|
||||
key={item.path}
|
||||
onClick={() => setIsNavigationOpen(false)}
|
||||
to={item.path}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<small>{item.description}</small>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
{isNavigationOpen ? (
|
||||
<button
|
||||
aria-label="Close navigation backdrop"
|
||||
className="navigation-backdrop"
|
||||
onClick={() => setIsNavigationOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
<div className="workspace">
|
||||
<header className="mobile-header">
|
||||
<span>Reddit Bot</span>
|
||||
<button
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={isNavigationOpen}
|
||||
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||
className="icon-button"
|
||||
onClick={() => setIsNavigationOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||
</button>
|
||||
</header>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface NavigationItem {
|
||||
label: string;
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const navigationItems: readonly NavigationItem[] = [
|
||||
{ label: 'Overview', path: '/', description: 'System health and current activity' },
|
||||
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
|
||||
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
|
||||
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
|
||||
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
|
||||
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 15_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
|
||||
import { AppShell } from './app-shell.js';
|
||||
import { OverviewPage } from '../features/overview/overview-page.js';
|
||||
import { PlaceholderPage } from '../features/placeholder-page.js';
|
||||
import { ConversationRoute } from '../features/conversations/conversation-route.js';
|
||||
import { MediaPage } from '../features/media/media-page.js';
|
||||
import { OperationsPage } from '../features/operations/operations-page.js';
|
||||
import { PerformancePage } from '../features/performance/performance-page.js';
|
||||
|
||||
export function createDashboardRoutes(): RouteObject[] {
|
||||
return [
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
{ path: 'conversations', element: <ConversationRoute /> },
|
||||
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
||||
{ path: 'media', element: <MediaPage /> },
|
||||
{ path: 'performance', element: <PerformancePage /> },
|
||||
{ path: 'operations', element: <OperationsPage /> },
|
||||
{ path: 'settings', element: <PlaceholderPage title="Settings" description="Manage runtime behavior and Reddit access." /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const dashboardRouter = createBrowserRouter(createDashboardRoutes());
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
action: 'start' | 'stop';
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const isStarting = action === 'start';
|
||||
|
||||
useEffect(() => {
|
||||
cancelButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
||||
<div className="dialog-card">
|
||||
<span className="eyebrow">Confirm action</span>
|
||||
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
|
||||
<p>
|
||||
{isStarting
|
||||
? 'The bot will resume automated processing.'
|
||||
: 'The bot will pause after its current operation.'}
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Applying…' : `Confirm ${action}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
ConversationsResponseSchema,
|
||||
ConversationDetailResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
SendReplyRequestSchema,
|
||||
StartConversationRequestSchema,
|
||||
StartConversationResponseSchema,
|
||||
SuccessResponseSchema,
|
||||
SuggestedReplyResponseSchema,
|
||||
type ActiveChat,
|
||||
type ConversationDetailResponse,
|
||||
type SuccessResponse,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsedError = ErrorResponseSchema.safeParse(payload);
|
||||
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
function conversationPath(id: string): string {
|
||||
return `/api/conversations/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
export interface ConversationApi {
|
||||
list(): Promise<ActiveChat[]>;
|
||||
detail(id: string): Promise<ConversationDetailResponse>;
|
||||
suggestReply(id: string): Promise<string>;
|
||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||
startConversation(username: string, message: string): Promise<ActiveChat>;
|
||||
}
|
||||
|
||||
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
|
||||
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(input, init);
|
||||
}
|
||||
|
||||
return {
|
||||
async list() {
|
||||
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema);
|
||||
},
|
||||
async detail(id) {
|
||||
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
|
||||
},
|
||||
async suggestReply(id) {
|
||||
const response = await parseResponse(
|
||||
await request(`${conversationPath(id)}/suggest-reply`, { method: 'POST' }),
|
||||
SuggestedReplyResponseSchema,
|
||||
);
|
||||
return response.suggestedReply;
|
||||
},
|
||||
async sendReply(id, message) {
|
||||
const body = SendReplyRequestSchema.parse({ message: message.trim() });
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/send-reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async startConversation(username, message) {
|
||||
const body = StartConversationRequestSchema.parse({
|
||||
username: username.trim().replace(/^u\//i, ''),
|
||||
message: message.trim(),
|
||||
});
|
||||
const response = await parseResponse(
|
||||
await request('/api/conversations/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
StartConversationResponseSchema,
|
||||
);
|
||||
return response.chat;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const conversationApi = createConversationApi();
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { useConversationDetailQuery, useSendReplyMutation, useSuggestReplyMutation } from './conversation-queries.js';
|
||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||
|
||||
interface ConversationDetailProps {
|
||||
conversationId: string;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
function formatMessageTime(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
|
||||
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
|
||||
if (!conversation.messages || conversation.messages.length === 0) {
|
||||
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol aria-label="Message history" className="conversation-messages">
|
||||
{conversation.messages.map((message) => (
|
||||
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
|
||||
<div>
|
||||
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
|
||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
||||
</div>
|
||||
<p>{message.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [wasSent, setWasSent] = useState(false);
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username);
|
||||
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
|
||||
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
||||
|
||||
const closeConfirmation = useCallback(() => {
|
||||
setIsConfirming(false);
|
||||
queueMicrotask(() => sendButtonRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
function suggestReply() {
|
||||
setWasSent(false);
|
||||
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
||||
}
|
||||
|
||||
function sendReply() {
|
||||
if (!canSend) return;
|
||||
sendMutation.mutate(draft, {
|
||||
onSuccess() {
|
||||
setDraft('');
|
||||
setWasSent(true);
|
||||
},
|
||||
onSettled: closeConfirmation,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
|
||||
<div className="conversation-section-heading">
|
||||
<h3 id="reply-composer-heading">Reply</h3>
|
||||
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
|
||||
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
|
||||
<textarea
|
||||
id="conversation-reply"
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setWasSent(false);
|
||||
}}
|
||||
placeholder="Write or generate a reply"
|
||||
rows={5}
|
||||
value={draft}
|
||||
/>
|
||||
{suggestionMutation.isError ? <div className="conversation-inline-error" role="alert">{suggestionMutation.error.message}</div> : null}
|
||||
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
|
||||
{wasSent ? (
|
||||
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
|
||||
) : null}
|
||||
<div className="conversation-actions">
|
||||
<span>{draft.length} characters</span>
|
||||
<button
|
||||
aria-label="Review and send reply"
|
||||
className="conversation-primary"
|
||||
disabled={!canSend}
|
||||
onClick={() => setIsConfirming(true)}
|
||||
ref={sendButtonRef}
|
||||
type="button"
|
||||
>
|
||||
Review and send
|
||||
</button>
|
||||
</div>
|
||||
{isConfirming ? (
|
||||
<SendConfirmationDialog
|
||||
draft={draft.trim()}
|
||||
isPending={sendMutation.isPending}
|
||||
onCancel={closeConfirmation}
|
||||
onConfirm={sendReply}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
|
||||
const detailQuery = useConversationDetailQuery(conversationId);
|
||||
|
||||
if (detailQuery.isPending) {
|
||||
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
|
||||
}
|
||||
if (detailQuery.isError) {
|
||||
return (
|
||||
<div className="conversation-detail-state conversation-inline-error" role="alert">
|
||||
<strong>Conversation unavailable</strong>
|
||||
<span>{detailQuery.error.message}</span>
|
||||
<button onClick={() => void detailQuery.refetch()} type="button">Retry conversation</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const conversation = detailQuery.data;
|
||||
return (
|
||||
<article className="conversation-detail">
|
||||
<header>
|
||||
<div>
|
||||
<span className="conversation-eyebrow">Conversation</span>
|
||||
<h2>u/{conversation.username}</h2>
|
||||
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
|
||||
</div>
|
||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||
</header>
|
||||
{conversation.syncInProgress ? (
|
||||
<div className="conversation-sync-notice" role="status">
|
||||
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
|
||||
</div>
|
||||
) : null}
|
||||
{conversation.triggeringPost ? (
|
||||
<a
|
||||
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
|
||||
className="triggering-post"
|
||||
href={conversation.triggeringPost.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
|
||||
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
|
||||
</a>
|
||||
) : null}
|
||||
<MessageHistory conversation={conversation} />
|
||||
<ConversationComposer conversation={conversation} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { conversationApi } from './conversation-api.js';
|
||||
|
||||
export const conversationQueryKeys = {
|
||||
all: ['conversations'] as const,
|
||||
detail: (id: string) => ['conversations', 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function useConversationsQuery() {
|
||||
return useQuery({ queryKey: conversationQueryKeys.all, queryFn: conversationApi.list });
|
||||
}
|
||||
|
||||
export function useConversationDetailQuery(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: conversationQueryKeys.detail(id ?? ''),
|
||||
queryFn: () => conversationApi.detail(id ?? ''),
|
||||
enabled: id !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSuggestReplyMutation(id: string) {
|
||||
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
|
||||
}
|
||||
|
||||
export function useSendReplyMutation(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (message: string) => conversationApi.sendReply(id, message),
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartConversationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ username, message }: { username: string; message: string }) =>
|
||||
conversationApi.startConversation(username, message),
|
||||
async onSuccess() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ConversationsPage } from './conversations-page.js';
|
||||
|
||||
export function ConversationRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
|
||||
function selectConversation(nextConversationId: string | null) {
|
||||
void navigate(
|
||||
nextConversationId
|
||||
? `/conversations/${encodeURIComponent(nextConversationId)}`
|
||||
: '/conversations',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationsPage
|
||||
selectedConversationId={conversationId ?? null}
|
||||
onSelectedConversationChange={selectConversation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { ConversationDetail } from './conversation-detail.js';
|
||||
import { useConversationsQuery } from './conversation-queries.js';
|
||||
import { StartConversationForm } from './start-conversation-form.js';
|
||||
import './conversations.css';
|
||||
|
||||
type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
|
||||
|
||||
export interface ConversationsPageProps {
|
||||
selectedConversationId?: string | null;
|
||||
onSelectedConversationChange?(conversationId: string | null): void;
|
||||
}
|
||||
|
||||
function conversationId(conversation: ActiveChat): string {
|
||||
return conversation.roomId ?? conversation.username;
|
||||
}
|
||||
|
||||
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
||||
if (filter === 'unread') return conversation.unread;
|
||||
if (filter === 'awaiting-reply') return conversation.isFromUs;
|
||||
return true;
|
||||
}
|
||||
|
||||
function ConversationListItem({ conversation, isSelected, onSelect }: {
|
||||
conversation: ActiveChat;
|
||||
isSelected: boolean;
|
||||
onSelect(): void;
|
||||
}) {
|
||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`u/${conversation.username}, ${state}`}
|
||||
aria-pressed={isSelected}
|
||||
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
|
||||
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange }: ConversationsPageProps) {
|
||||
const conversationsQuery = useConversationsQuery();
|
||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<ConversationFilter>('all');
|
||||
const [isStartingConversation, setIsStartingConversation] = useState(false);
|
||||
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||
|
||||
const visibleConversations = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return (conversationsQuery.data ?? []).filter((conversation) => {
|
||||
const matchesSearch = normalizedSearch.length === 0
|
||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
||||
return matchesSearch && matchesFilter(conversation, filter);
|
||||
});
|
||||
}, [conversationsQuery.data, filter, search]);
|
||||
|
||||
function selectConversation(id: string | null) {
|
||||
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
||||
onSelectedConversationChange?.(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="conversations-page">
|
||||
<header className="conversations-header">
|
||||
<div>
|
||||
<span className="conversation-eyebrow">Inbox</span>
|
||||
<h1>Conversations</h1>
|
||||
<p>Review replies, draft carefully, and send only after confirmation.</p>
|
||||
</div>
|
||||
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isStartingConversation ? (
|
||||
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
|
||||
<h2 id="new-conversation-heading">Start a conversation</h2>
|
||||
<StartConversationForm onStarted={(id) => selectConversation(id)} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="conversation-workspace">
|
||||
<section aria-label="Conversation inbox" className="conversation-master">
|
||||
<div className="conversation-toolbar">
|
||||
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
|
||||
<input
|
||||
id="conversation-search"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search conversations"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
|
||||
<select
|
||||
id="conversation-filter"
|
||||
onChange={(event) => setFilter(event.target.value as ConversationFilter)}
|
||||
value={filter}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="awaiting-reply">Awaiting reply</option>
|
||||
</select>
|
||||
</div>
|
||||
{conversationsQuery.isPending ? (
|
||||
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
|
||||
) : conversationsQuery.isError ? (
|
||||
<div className="conversation-list-state conversation-inline-error" role="alert">
|
||||
<strong>Could not load conversations</strong>
|
||||
<span>{conversationsQuery.error.message}</span>
|
||||
<button onClick={() => void conversationsQuery.refetch()} type="button">Retry conversations</button>
|
||||
</div>
|
||||
) : conversationsQuery.data.length === 0 ? (
|
||||
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
|
||||
) : visibleConversations.length === 0 ? (
|
||||
<div className="conversation-list-state"><strong>No matching conversations.</strong><span>Try a different search or filter.</span></div>
|
||||
) : (
|
||||
<ul className="conversation-list">
|
||||
{visibleConversations.map((conversation) => (
|
||||
<ConversationListItem
|
||||
conversation={conversation}
|
||||
isSelected={conversationId(conversation) === activeConversationId}
|
||||
key={conversationId(conversation)}
|
||||
onSelect={() => selectConversation(conversationId(conversation))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-label="Selected conversation" className="conversation-detail-panel">
|
||||
{activeConversationId ? (
|
||||
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
|
||||
) : (
|
||||
<div className="conversation-detail-state">
|
||||
<strong>Select a conversation</strong>
|
||||
<span>Message history and reply tools will appear here.</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.conversations-page {
|
||||
width: min(86rem, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.conversations-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conversations-header h1 { margin-bottom: .4rem; }
|
||||
.conversations-header p { margin-bottom: 0; color: var(--muted); }
|
||||
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
|
||||
.conversations-page button, .conversations-page input, .conversations-page select, .conversations-page textarea { font: inherit; }
|
||||
.conversations-page button { min-height: 2.5rem; padding: .5rem .8rem; border: 1px solid var(--border); border-radius: .6rem; color: #e9f1ec; background: var(--surface-raised); }
|
||||
.conversations-page button:disabled { cursor: wait; opacity: .58; }
|
||||
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||
.new-conversation-panel h2 { margin-bottom: 1rem; }
|
||||
.start-conversation-form { display: grid; gap: .8rem; }
|
||||
.conversation-field { display: grid; gap: .35rem; }
|
||||
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
|
||||
.conversation-field input, .conversation-field textarea, .conversation-toolbar input, .conversation-toolbar select, .conversation-composer textarea {
|
||||
width: 100%; padding: .7rem .75rem; color: #e9f1ec; background: #0b1410; border: 1px solid var(--border); border-radius: .55rem;
|
||||
}
|
||||
.conversation-field input:focus, .conversation-field textarea:focus, .conversation-toolbar input:focus, .conversation-toolbar select:focus, .conversation-composer textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.conversation-workspace { display: grid; gap: .75rem; }
|
||||
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
|
||||
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
|
||||
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
|
||||
.conversation-list-item { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
|
||||
.conversation-list-item:hover { background: var(--surface-raised) !important; }
|
||||
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
|
||||
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
||||
.conversation-list-heading small { color: var(--accent); }
|
||||
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
|
||||
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||
.conversation-detail { min-height: 32rem; }
|
||||
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.conversation-detail h2 { display: inline; margin-right: .6rem; }
|
||||
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
|
||||
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
|
||||
.triggering-post { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
|
||||
.triggering-post span { color: var(--muted); font-size: .75rem; }
|
||||
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
|
||||
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
|
||||
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
|
||||
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
|
||||
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
|
||||
.conversation-messages time { color: var(--muted); font-size: .7rem; }
|
||||
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
|
||||
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
|
||||
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
|
||||
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.conversation-section-heading h3 { margin: 0; }
|
||||
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
||||
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
|
||||
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
|
||||
.conversation-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
|
||||
.conversation-dialog { width: min(30rem, 100%); padding: 1.25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .9rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .45); }
|
||||
.conversation-dialog p { color: var(--muted); }
|
||||
.conversation-dialog blockquote { max-height: 12rem; padding: .75rem; margin: 1rem 0; overflow: auto; background: #0b1410; border-left: 3px solid var(--accent); white-space: pre-wrap; }
|
||||
.conversation-dialog .conversation-actions { justify-content: flex-end; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
@media (min-width: 52rem) {
|
||||
.conversations-page { padding: 3rem 2rem 5rem; }
|
||||
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
|
||||
.conversation-workspace { grid-template-columns: minmax(17rem, .75fr) minmax(28rem, 1.5fr); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface SendConfirmationDialogProps {
|
||||
draft: string;
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelButtonRef.current?.focus();
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !isPending) onCancel();
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isPending, onCancel]);
|
||||
|
||||
return (
|
||||
<div aria-labelledby="send-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
|
||||
<div className="conversation-dialog">
|
||||
<span className="conversation-eyebrow">Confirm message</span>
|
||||
<h2 id="send-confirmation-title">Send this reply?</h2>
|
||||
<p>This sends immediately through Reddit chat.</p>
|
||||
<blockquote>{draft}</blockquote>
|
||||
<div className="conversation-actions">
|
||||
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Sending…' : 'Confirm send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { useStartConversationMutation } from './conversation-queries.js';
|
||||
|
||||
interface StartConversationFormProps {
|
||||
onStarted(conversationId: string): void;
|
||||
}
|
||||
|
||||
export function StartConversationForm({ onStarted }: StartConversationFormProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [successUsername, setSuccessUsername] = useState<string | null>(null);
|
||||
const mutation = useStartConversationMutation();
|
||||
const canSubmit = username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
mutation.mutate(
|
||||
{ username, message },
|
||||
{
|
||||
onSuccess(chat) {
|
||||
setSuccessUsername(chat.username);
|
||||
setUsername('');
|
||||
setMessage('');
|
||||
onStarted(chat.roomId ?? chat.username);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="start-conversation-form" onSubmit={submit}>
|
||||
<div className="conversation-field">
|
||||
<label htmlFor="new-conversation-username">Reddit username</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
id="new-conversation-username"
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="username"
|
||||
value={username}
|
||||
/>
|
||||
</div>
|
||||
<div className="conversation-field">
|
||||
<label htmlFor="new-conversation-message">Opening message</label>
|
||||
<textarea
|
||||
id="new-conversation-message"
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="Write a personal opening message"
|
||||
rows={4}
|
||||
value={message}
|
||||
/>
|
||||
</div>
|
||||
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
|
||||
{successUsername ? (
|
||||
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
|
||||
Conversation started with u/{successUsername}
|
||||
</div>
|
||||
) : null}
|
||||
<button className="conversation-primary" disabled={!canSubmit} type="submit">
|
||||
{mutation.isPending ? 'Starting…' : 'Start conversation'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
MediaResponseSchema,
|
||||
MediaSyncStatusSchema,
|
||||
MediaSyncTriggerResponseSchema,
|
||||
type MediaItem,
|
||||
type MediaSyncStatus,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsedError = ErrorResponseSchema.safeParse(payload);
|
||||
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
function request(path: string, init?: RequestInit): Promise<Response> {
|
||||
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
|
||||
}
|
||||
|
||||
export async function fetchMedia(): Promise<MediaItem[]> {
|
||||
return parseResponse(await request('/api/media'), MediaResponseSchema);
|
||||
}
|
||||
|
||||
export async function fetchMediaSyncStatus(): Promise<MediaSyncStatus> {
|
||||
return parseResponse(await request('/api/media/sync'), MediaSyncStatusSchema);
|
||||
}
|
||||
|
||||
export async function triggerMediaSync(isFull: boolean): Promise<void> {
|
||||
const path = isFull ? '/api/media/sync/full' : '/api/media/sync';
|
||||
const response = await parseResponse(
|
||||
await request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: isFull ? undefined : JSON.stringify({ full: false }),
|
||||
}),
|
||||
MediaSyncTriggerResponseSchema,
|
||||
);
|
||||
if (!response.success) throw new Error(response.reason);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export interface MediaFilters {
|
||||
conversation: string;
|
||||
sender: string;
|
||||
type: 'all' | 'image' | 'gif';
|
||||
dateRange: 'all' | '24h' | '7d' | '30d';
|
||||
}
|
||||
|
||||
export function mediaConversation(item: MediaItem): string {
|
||||
return item.chatUsername ?? item.chatRoomId ?? 'unknown';
|
||||
}
|
||||
|
||||
export function mediaType(item: MediaItem): 'image' | 'gif' {
|
||||
return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image';
|
||||
}
|
||||
|
||||
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
|
||||
const rangeMs = {
|
||||
all: null,
|
||||
'24h': 86_400_000,
|
||||
'7d': 7 * 86_400_000,
|
||||
'30d': 30 * 86_400_000,
|
||||
}[filters.dateRange];
|
||||
|
||||
return items.filter((item) => {
|
||||
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
||||
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
||||
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
||||
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface FullSyncDialogProps {
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div aria-labelledby="full-sync-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
||||
<div className="dialog-card">
|
||||
<span className="eyebrow">Confirm expensive action</span>
|
||||
<h2 id="full-sync-title">Run full media sync?</h2>
|
||||
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
|
||||
<div className="dialog-actions">
|
||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Syncing…' : 'Run full sync'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { mediaConversation, mediaType } from './filters.js';
|
||||
|
||||
interface MediaCardProps {
|
||||
item: MediaItem;
|
||||
isRevealed: boolean;
|
||||
onToggleReveal(): void;
|
||||
}
|
||||
|
||||
function formatMediaDate(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
|
||||
export function MediaCard({ item, isRevealed, onToggleReveal }: MediaCardProps) {
|
||||
return (
|
||||
<article className="media-card">
|
||||
<div className="media-preview">
|
||||
<img
|
||||
alt={`Media from ${item.sender}`}
|
||||
className={isRevealed ? 'media-image' : 'media-image is-blurred'}
|
||||
loading="lazy"
|
||||
src={item.urlPath}
|
||||
/>
|
||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||
</div>
|
||||
<div className="media-card-body">
|
||||
<div>
|
||||
<strong>{item.sender}</strong>
|
||||
<span>{mediaConversation(item)}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
|
||||
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
|
||||
</dl>
|
||||
<button
|
||||
className="button secondary media-reveal"
|
||||
onClick={onToggleReveal}
|
||||
type="button"
|
||||
>
|
||||
{isRevealed ? `Hide media from ${item.sender}` : `Reveal media from ${item.sender}`}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { filterMedia, mediaConversation, type MediaFilters } from './filters.js';
|
||||
import { FullSyncDialog } from './full-sync-dialog.js';
|
||||
import { MediaCard } from './media-card.js';
|
||||
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
|
||||
import './media.css';
|
||||
|
||||
const initialFilters: MediaFilters = {
|
||||
conversation: 'all',
|
||||
sender: 'all',
|
||||
type: 'all',
|
||||
dateRange: 'all',
|
||||
};
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function formatLastSync(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
|
||||
export function MediaPage() {
|
||||
const mediaQuery = useMediaQuery();
|
||||
const statusQuery = useMediaSyncStatusQuery();
|
||||
const syncMutation = useTriggerMediaSyncMutation();
|
||||
const [filters, setFilters] = useState<MediaFilters>(initialFilters);
|
||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
|
||||
const [isRevealAll, setIsRevealAll] = useState(false);
|
||||
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
||||
|
||||
const items = mediaQuery.data ?? [];
|
||||
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
|
||||
const conversations = useMemo(
|
||||
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
|
||||
[items],
|
||||
);
|
||||
const senders = useMemo(
|
||||
() => [...new Set(items.map((item) => item.sender))].sort((left, right) => left.localeCompare(right)),
|
||||
[items],
|
||||
);
|
||||
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
||||
|
||||
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleItem(itemId: string) {
|
||||
if (isRevealAll) {
|
||||
setIsRevealAll(false);
|
||||
setRevealedIds(new Set(items.filter((item) => item.id !== itemId).map((item) => item.id)));
|
||||
return;
|
||||
}
|
||||
setRevealedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(itemId)) next.delete(itemId);
|
||||
else next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function runSync(isFull: boolean) {
|
||||
syncMutation.mutate(isFull, {
|
||||
onSuccess: () => {
|
||||
if (isFull) setIsFullSyncDialogOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
if (isFull) setIsFullSyncDialogOpen(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaQuery.isPending) {
|
||||
return <div aria-label="Loading media" className="page-state" role="status"><span className="spinner" />Loading media…</div>;
|
||||
}
|
||||
|
||||
if (mediaQuery.isError) {
|
||||
return (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Media unavailable</strong>
|
||||
<span>{errorMessage(mediaQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void mediaQuery.refetch()} type="button">Retry media</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page media-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Private gallery</span>
|
||||
<h1>Media</h1>
|
||||
<p>Browse authenticated conversation media. Everything stays blurred until you choose to reveal it.</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{items.length > 0 ? (
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => {
|
||||
setIsRevealAll((current) => !current);
|
||||
setRevealedIds(new Set());
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={isSyncBusy}
|
||||
onClick={() => {
|
||||
syncMutation.reset();
|
||||
setIsFullSyncDialogOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Full sync
|
||||
</button>
|
||||
<button className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
|
||||
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Media sync status" className="status-strip media-sync-status">
|
||||
{statusQuery.isPending ? <span role="status">Loading sync status…</span> : null}
|
||||
{statusQuery.isError ? (
|
||||
<div role="alert">
|
||||
<span>{errorMessage(statusQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry sync status</button>
|
||||
</div>
|
||||
) : null}
|
||||
{statusQuery.data ? (
|
||||
statusQuery.data.isSyncing ? (
|
||||
<div role="status"><span className="spinner" /><strong>Media sync in progress</strong></div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="status-dot running" aria-hidden="true" />
|
||||
<div>
|
||||
<small>Sync status</small>
|
||||
<strong>{statusQuery.data.lastSyncChats === null ? 'No completed sync yet' : `Last synced ${statusQuery.data.lastSyncChats} conversations`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{statusQuery.data?.lastSyncAt ? <span className="status-note">{formatLastSync(statusQuery.data.lastSyncAt)}</span> : null}
|
||||
</section>
|
||||
|
||||
{statusQuery.data?.lastError ? <div className="cooldown-banner" role="alert"><strong>Last sync failed</strong><span>{statusQuery.data.lastError}</span></div> : null}
|
||||
{syncMutation.isSuccess ? <div className="media-notice success" role="status">{syncMutation.variables ? 'Full media sync completed.' : 'Media sync completed.'}</div> : null}
|
||||
{syncMutation.isError ? <div className="media-notice error" role="alert">{errorMessage(syncMutation.error)}</div> : null}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<section className="panel empty-state"><strong>No media yet.</strong><span>Use Sync new media to check recent conversations.</span></section>
|
||||
) : (
|
||||
<>
|
||||
<section aria-label="Media filters" className="panel media-filters">
|
||||
<label>Conversation
|
||||
<select value={filters.conversation} onChange={(event) => updateFilter('conversation', event.target.value)}>
|
||||
<option value="all">All conversations</option>
|
||||
{conversations.map((conversation) => <option key={conversation} value={conversation}>{conversation}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Sender
|
||||
<select value={filters.sender} onChange={(event) => updateFilter('sender', event.target.value)}>
|
||||
<option value="all">All senders</option>
|
||||
{senders.map((sender) => <option key={sender} value={sender}>{sender}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Media type
|
||||
<select value={filters.type} onChange={(event) => updateFilter('type', event.target.value as MediaFilters['type'])}>
|
||||
<option value="all">All types</option><option value="image">Images</option><option value="gif">GIFs</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Date range
|
||||
<select value={filters.dateRange} onChange={(event) => updateFilter('dateRange', event.target.value as MediaFilters['dateRange'])}>
|
||||
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{visibleItems.length === 0 ? (
|
||||
<section className="panel empty-state"><strong>No media matches these filters.</strong><span>Try broadening one or more filters.</span></section>
|
||||
) : (
|
||||
<section aria-label="Media gallery" className="media-grid">
|
||||
{visibleItems.map((item) => (
|
||||
<MediaCard
|
||||
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onToggleReveal={() => toggleItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFullSyncDialogOpen ? (
|
||||
<FullSyncDialog
|
||||
isPending={syncMutation.isPending}
|
||||
onCancel={() => setIsFullSyncDialogOpen(false)}
|
||||
onConfirm={() => runSync(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.media-page { max-width: 86rem; }
|
||||
.media-sync-status { min-height: 4.4rem; }
|
||||
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
|
||||
.media-sync-status .spinner { width: 1rem; height: 1rem; }
|
||||
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
|
||||
.media-notice.success { color: #aaf0c4; background: #163a26; border: 1px solid #28593d; }
|
||||
.media-notice.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
|
||||
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
||||
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
||||
.media-filters select:focus-visible, .media-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
||||
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
|
||||
.media-image { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
|
||||
.media-image.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
|
||||
.privacy-label { position: absolute; padding: .45rem .65rem; color: white; background: rgba(0, 0, 0, .72); border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
||||
.media-card-body { display: grid; gap: .8rem; padding: .9rem; }
|
||||
.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; }
|
||||
.media-card-body > div:first-child span { color: var(--muted); }
|
||||
.media-card-body dl { display: grid; gap: .4rem; margin: 0; }
|
||||
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
||||
.media-card-body dt { color: var(--muted); }
|
||||
.media-card-body dd { margin: 0; text-align: right; }
|
||||
.media-reveal { width: 100%; }
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (min-width: 72rem) {
|
||||
.media-filters { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchMedia, fetchMediaSyncStatus, triggerMediaSync } from './api.js';
|
||||
|
||||
export const mediaQueryKeys = {
|
||||
items: ['dashboard', 'media'] as const,
|
||||
syncStatus: ['dashboard', 'media', 'sync-status'] as const,
|
||||
};
|
||||
|
||||
export function useMediaQuery() {
|
||||
return useQuery({ queryKey: mediaQueryKeys.items, queryFn: fetchMedia });
|
||||
}
|
||||
|
||||
export function useMediaSyncStatusQuery() {
|
||||
return useQuery({
|
||||
queryKey: mediaQueryKeys.syncStatus,
|
||||
queryFn: fetchMediaSyncStatus,
|
||||
refetchInterval(query) {
|
||||
return query.state.data?.isSyncing ? 1_000 : 15_000;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTriggerMediaSyncMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn(isFull: boolean) {
|
||||
const syncRequest = triggerMediaSync(isFull);
|
||||
void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus });
|
||||
return syncRequest;
|
||||
},
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: mediaQueryKeys.items }),
|
||||
queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus }),
|
||||
]);
|
||||
},
|
||||
onError() {
|
||||
void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type ConsoleLevelFilter = 'all' | ConsoleLevel;
|
||||
|
||||
export interface ConsoleEntry {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
level: ConsoleLevel;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface LiveConsoleState {
|
||||
capacity: number;
|
||||
entries: readonly ConsoleEntry[];
|
||||
pendingEntries: readonly ConsoleEntry[];
|
||||
isPaused: boolean;
|
||||
isAutoScrollEnabled: boolean;
|
||||
level: ConsoleLevelFilter;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export type LiveConsoleAction =
|
||||
| { type: 'append'; entry: ConsoleEntry }
|
||||
| { type: 'set-paused'; paused: boolean }
|
||||
| { type: 'set-auto-scroll'; enabled: boolean }
|
||||
| { type: 'set-level'; level: ConsoleLevelFilter }
|
||||
| { type: 'set-search'; search: string };
|
||||
|
||||
function boundedAppend(
|
||||
entries: readonly ConsoleEntry[],
|
||||
entry: ConsoleEntry,
|
||||
capacity: number,
|
||||
): readonly ConsoleEntry[] {
|
||||
if (entries.some((candidate) => isSameEntry(candidate, entry))) return entries;
|
||||
return [...entries, entry].slice(-capacity);
|
||||
}
|
||||
|
||||
function isSameEntry(left: ConsoleEntry, right: ConsoleEntry): boolean {
|
||||
return left.id === right.id || (
|
||||
left.occurredAt === right.occurredAt &&
|
||||
left.level === right.level &&
|
||||
left.message === right.message &&
|
||||
searchableData(left.data) === searchableData(right.data)
|
||||
);
|
||||
}
|
||||
|
||||
export function createLiveConsoleState(capacity = 500): LiveConsoleState {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new RangeError('capacity must be a positive integer');
|
||||
}
|
||||
return {
|
||||
capacity,
|
||||
entries: [],
|
||||
pendingEntries: [],
|
||||
isPaused: false,
|
||||
isAutoScrollEnabled: true,
|
||||
level: 'all',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceLiveConsoleState(
|
||||
state: LiveConsoleState,
|
||||
action: LiveConsoleAction,
|
||||
): LiveConsoleState {
|
||||
switch (action.type) {
|
||||
case 'append': {
|
||||
if (
|
||||
state.entries.some((entry) => isSameEntry(entry, action.entry)) ||
|
||||
state.pendingEntries.some((entry) => isSameEntry(entry, action.entry))
|
||||
) return state;
|
||||
return state.isPaused
|
||||
? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) }
|
||||
: { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) };
|
||||
}
|
||||
case 'set-paused':
|
||||
if (action.paused === state.isPaused) return state;
|
||||
return action.paused
|
||||
? { ...state, isPaused: true }
|
||||
: {
|
||||
...state,
|
||||
isPaused: false,
|
||||
entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity),
|
||||
pendingEntries: [],
|
||||
};
|
||||
case 'set-auto-scroll':
|
||||
return { ...state, isAutoScrollEnabled: action.enabled };
|
||||
case 'set-level':
|
||||
return { ...state, level: action.level };
|
||||
case 'set-search':
|
||||
return { ...state, search: action.search };
|
||||
}
|
||||
}
|
||||
|
||||
function searchableData(data: unknown): string {
|
||||
if (data === undefined) return '';
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function selectVisibleConsoleEntries(
|
||||
state: LiveConsoleState,
|
||||
): readonly ConsoleEntry[] {
|
||||
const search = state.search.trim().toLocaleLowerCase();
|
||||
return state.entries.filter((entry) => {
|
||||
if (state.level !== 'all' && entry.level !== state.level) return false;
|
||||
if (search.length === 0) return true;
|
||||
return `${entry.message} ${searchableData(entry.data)}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(search);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ActivityResponseSchema,
|
||||
ChatSyncStatusSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export const RawLogEntrySchema = z.strictObject({
|
||||
timestamp: z.number(),
|
||||
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
|
||||
message: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
export const RawLogsResponseSchema = z.array(RawLogEntrySchema);
|
||||
export type RawLogEntry = z.infer<typeof RawLogEntrySchema>;
|
||||
|
||||
async function parseResponse<T>(
|
||||
response: Response,
|
||||
schema: { parse(value: unknown): T },
|
||||
): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
'error' in payload &&
|
||||
typeof payload.error === 'string'
|
||||
? payload.error
|
||||
: `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export function createOperationsApi(fetchImplementation?: typeof fetch) {
|
||||
function request(path: string): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(path);
|
||||
}
|
||||
return {
|
||||
async getActivity() {
|
||||
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
|
||||
},
|
||||
async getLogs(limit = 200) {
|
||||
return parseResponse(
|
||||
await request(`/api/logs?limit=${limit}`),
|
||||
RawLogsResponseSchema,
|
||||
);
|
||||
},
|
||||
async getSyncStatus() {
|
||||
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const operationsApi = createOperationsApi();
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
createLiveConsoleState,
|
||||
reduceLiveConsoleState,
|
||||
selectVisibleConsoleEntries,
|
||||
type ConsoleEntry,
|
||||
type ConsoleLevelFilter,
|
||||
} from './live-console-model.js';
|
||||
import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js';
|
||||
import {
|
||||
createBrowserEventSource,
|
||||
createDashboardRealtimeClient,
|
||||
queryKeysForDashboardEvent,
|
||||
type RealtimeConnectionSnapshot,
|
||||
} from './realtime-client.js';
|
||||
import './operations.css';
|
||||
|
||||
function formatTimestamp(timestamp: number | string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(typeof timestamp === 'number' ? timestamp : new Date(timestamp));
|
||||
}
|
||||
|
||||
function ActivityView({ items }: { items: readonly ActivityItem[] }) {
|
||||
return items.length === 0 ? (
|
||||
<p className="feature-empty">No recent operator activity.</p>
|
||||
) : (
|
||||
<ol className="operations-activity-list">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.timestamp}-${item.message}`}>
|
||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncSummary({ status }: { status: ChatSyncStatus }) {
|
||||
const progress = status.progress;
|
||||
return (
|
||||
<div className="sync-summary">
|
||||
<div><span className={`pill ${status.syncing ? 'warning' : 'success'}`}>{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}</span><strong>{status.cachedChats} cached conversations</strong></div>
|
||||
{progress ? (
|
||||
<div>
|
||||
<div className="sync-progress-label"><span>{progress.label ?? progress.phase}</span><strong>{progress.current} of {progress.total}</strong></div>
|
||||
<progress aria-label="Sync progress" max={Math.max(1, progress.total)} value={progress.current} />
|
||||
</div>
|
||||
) : null}
|
||||
<dl className="sync-history">
|
||||
<div><dt>Last delta sync</dt><dd>{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}</dd></div>
|
||||
<div><dt>Last full sync</dt><dd>{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
|
||||
switch (snapshot.status) {
|
||||
case 'connected': return 'Realtime connected';
|
||||
case 'connecting': return 'Connecting to realtime updates';
|
||||
case 'reconnecting': return 'Realtime reconnecting';
|
||||
case 'closed': return 'Realtime closed';
|
||||
case 'idle': return 'Realtime unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperationsPageProps {
|
||||
realtimeEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const activityQuery = useActivityQuery();
|
||||
const logsQuery = useLogsQuery();
|
||||
const syncQuery = useSyncStatusQuery();
|
||||
const [consoleState, dispatchConsole] = useReducer(
|
||||
reduceLiveConsoleState,
|
||||
createLiveConsoleState(500),
|
||||
);
|
||||
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({
|
||||
status: 'idle',
|
||||
isStale: true,
|
||||
});
|
||||
const consoleEnd = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logsQuery.data?.forEach((entry, index) => {
|
||||
dispatchConsole({
|
||||
type: 'append',
|
||||
entry: {
|
||||
id: `initial-${entry.timestamp}-${index}`,
|
||||
occurredAt: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
data: entry.data,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [logsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeEnabled || typeof EventSource === 'undefined') {
|
||||
setConnection({ status: 'idle', isStale: true });
|
||||
return;
|
||||
}
|
||||
function onEvent(event: DashboardEvent): void {
|
||||
if (event.type === 'log.created') {
|
||||
const entry: ConsoleEntry = {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
level: event.payload.level,
|
||||
message: event.payload.message,
|
||||
data: event.payload.data,
|
||||
};
|
||||
dispatchConsole({ type: 'append', entry });
|
||||
}
|
||||
for (const queryKey of queryKeysForDashboardEvent(event)) {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
}
|
||||
const client = createDashboardRealtimeClient({
|
||||
sourceFactory: createBrowserEventSource,
|
||||
onEvent,
|
||||
onStateChange: setConnection,
|
||||
});
|
||||
client.start();
|
||||
return () => client.stop();
|
||||
}, [queryClient, realtimeEnabled]);
|
||||
|
||||
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
||||
useEffect(() => {
|
||||
if (
|
||||
consoleState.isAutoScrollEnabled &&
|
||||
!consoleState.isPaused &&
|
||||
typeof consoleEnd.current?.scrollIntoView === 'function'
|
||||
) {
|
||||
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
|
||||
|
||||
return (
|
||||
<div className="page operations-page">
|
||||
<header className="page-header"><div><span className="eyebrow">Observability</span><h1>Operations</h1><p>Human-readable activity, background work, and raw diagnostic output in distinct views.</p></div></header>
|
||||
<div aria-label="Realtime connection" className={`connection-banner ${connection.isStale ? 'stale' : ''}`} role="status">
|
||||
<strong>{connectionLabel(connection)}</strong>
|
||||
{connection.isStale ? <span>State may be stale</span> : <span>Events are updating live</span>}
|
||||
</div>
|
||||
<div className="operations-summary-grid">
|
||||
<section aria-labelledby="operations-activity-heading" className="panel feature-panel" role="region">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Readable history</span><h2 id="operations-activity-heading">Activity</h2></div></div>
|
||||
{activityQuery.isPending ? <p role="status">Loading activity…</p> : activityQuery.isError ? <p role="alert">{activityQuery.error.message}</p> : <ActivityView items={activityQuery.data} />}
|
||||
</section>
|
||||
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
|
||||
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <SyncSummary status={syncQuery.data} />}
|
||||
</section>
|
||||
</div>
|
||||
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
|
||||
<div className="console-controls">
|
||||
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
|
||||
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
|
||||
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
|
||||
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
|
||||
</div>
|
||||
<div aria-live="polite" className="live-console" role="log">
|
||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
|
||||
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
|
||||
))}
|
||||
<div ref={consoleEnd} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { operationsApi } from './operations-api.js';
|
||||
|
||||
export const operationsQueryKeys = {
|
||||
activity: ['operations', 'activity'] as const,
|
||||
logs: ['operations', 'logs'] as const,
|
||||
syncStatus: ['operations', 'sync-status'] as const,
|
||||
};
|
||||
|
||||
export function useActivityQuery() {
|
||||
return useQuery({ queryKey: operationsQueryKeys.activity, queryFn: operationsApi.getActivity });
|
||||
}
|
||||
|
||||
export function useLogsQuery() {
|
||||
return useQuery({ queryKey: operationsQueryKeys.logs, queryFn: () => operationsApi.getLogs(200) });
|
||||
}
|
||||
|
||||
export function useSyncStatusQuery() {
|
||||
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.operations-page { display: grid; gap: .8rem; }
|
||||
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: #aaf0c4; background: #163a26; border: 1px solid #28593d; border-radius: .75rem; }
|
||||
.connection-banner.stale { color: #ffe1a0; background: #2a2111; border-color: #59451e; }
|
||||
.operations-summary-grid { display: grid; gap: .8rem; }
|
||||
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
||||
.operations-activity-list li { display: flex; gap: .75rem; }
|
||||
.operations-activity-list li div { display: grid; gap: .2rem; }
|
||||
.operations-activity-list small { color: var(--muted); }
|
||||
.sync-summary { display: grid; gap: 1rem; }
|
||||
.sync-summary > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.sync-progress-label { display: flex; justify-content: space-between; gap: .75rem; margin-bottom: .45rem; color: var(--muted); }
|
||||
.sync-summary progress { width: 100%; accent-color: var(--accent); }
|
||||
.sync-history { display: grid; gap: .5rem; margin: 0; }
|
||||
.sync-history div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .6rem; border-top: 1px solid var(--border); }
|
||||
.sync-history dt { color: var(--muted); }
|
||||
.sync-history dd { margin: 0; }
|
||||
.console-count { color: var(--muted); font-size: .8rem; }
|
||||
.console-controls { display: grid; gap: .65rem; margin-bottom: .8rem; }
|
||||
.console-controls label { display: grid; gap: .3rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
|
||||
.console-controls input, .console-controls select { min-height: 2.5rem; padding: .5rem .65rem; color: #e9f1ec; background: #09110e; border: 1px solid var(--border); border-radius: .55rem; }
|
||||
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
|
||||
.console-controls .checkbox-control input { min-height: auto; }
|
||||
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
|
||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||
.console-entry time { color: #708178; }
|
||||
.console-entry strong { color: #8da49a; }
|
||||
.console-entry.warn strong { color: var(--warning); }
|
||||
.console-entry.error strong { color: var(--danger); }
|
||||
.live-console > p { color: var(--muted); text-align: center; }
|
||||
@media (min-width: 56rem) {
|
||||
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { QueryKey } from '@tanstack/react-query';
|
||||
import {
|
||||
DashboardEventSchema,
|
||||
type DashboardEvent,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export const DASHBOARD_EVENT_TYPES = [
|
||||
'bot.status.changed',
|
||||
'message.sent',
|
||||
'message.failed',
|
||||
'conversation.updated',
|
||||
'reply.received',
|
||||
'sync.started',
|
||||
'sync.progress',
|
||||
'sync.completed',
|
||||
'sync.failed',
|
||||
'rate-limit.changed',
|
||||
'reddit.session.changed',
|
||||
'log.created',
|
||||
] as const;
|
||||
|
||||
export interface DashboardEventSource {
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface RealtimeConnectionSnapshot {
|
||||
status: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
||||
isStale: boolean;
|
||||
lastEventId?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRealtimeClient {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
getSnapshot(): RealtimeConnectionSnapshot;
|
||||
}
|
||||
|
||||
export interface DashboardRealtimeClientOptions {
|
||||
url?: string;
|
||||
sourceFactory(url: string): DashboardEventSource;
|
||||
onEvent(event: DashboardEvent): void;
|
||||
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
|
||||
onInvalidEvent?(value: unknown): void;
|
||||
}
|
||||
|
||||
function eventData(value: unknown): string | undefined {
|
||||
if (typeof value !== 'object' || value === null || !('data' in value)) return undefined;
|
||||
return typeof value.data === 'string' ? value.data : undefined;
|
||||
}
|
||||
|
||||
export function createDashboardRealtimeClient(
|
||||
options: DashboardRealtimeClientOptions,
|
||||
): DashboardRealtimeClient {
|
||||
let source: DashboardEventSource | undefined;
|
||||
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
|
||||
|
||||
function update(next: RealtimeConnectionSnapshot): void {
|
||||
snapshot = next;
|
||||
options.onStateChange?.(snapshot);
|
||||
}
|
||||
|
||||
function receive(value: unknown): void {
|
||||
const data = eventData(value);
|
||||
if (data === undefined) {
|
||||
options.onInvalidEvent?.(value);
|
||||
return;
|
||||
}
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(data);
|
||||
} catch {
|
||||
options.onInvalidEvent?.(data);
|
||||
return;
|
||||
}
|
||||
const result = DashboardEventSchema.safeParse(decoded);
|
||||
if (!result.success) {
|
||||
options.onInvalidEvent?.(decoded);
|
||||
return;
|
||||
}
|
||||
snapshot = { ...snapshot, lastEventId: result.data.id };
|
||||
options.onEvent(result.data);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (source !== undefined) return;
|
||||
update({ ...snapshot, status: 'connecting', isStale: true });
|
||||
source = options.sourceFactory(options.url ?? '/api/events/stream');
|
||||
source.addEventListener('open', () => {
|
||||
update({ ...snapshot, status: 'connected', isStale: false });
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
update({ ...snapshot, status: 'reconnecting', isStale: true });
|
||||
});
|
||||
for (const type of DASHBOARD_EVENT_TYPES) source.addEventListener(type, receive);
|
||||
},
|
||||
stop() {
|
||||
source?.close();
|
||||
source = undefined;
|
||||
update({ ...snapshot, status: 'closed', isStale: true });
|
||||
},
|
||||
getSnapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function queryKeysForDashboardEvent(event: DashboardEvent): readonly QueryKey[] {
|
||||
switch (event.type) {
|
||||
case 'bot.status.changed':
|
||||
return [['dashboard', 'stats']];
|
||||
case 'rate-limit.changed':
|
||||
return [['dashboard', 'stats'], ['dashboard', 'timers']];
|
||||
case 'message.sent':
|
||||
case 'message.failed':
|
||||
return [['conversations'], ['performance']];
|
||||
case 'conversation.updated':
|
||||
case 'reply.received':
|
||||
return [['conversations'], ['operations', 'activity']];
|
||||
case 'sync.started':
|
||||
case 'sync.progress':
|
||||
case 'sync.completed':
|
||||
case 'sync.failed':
|
||||
return [['operations', 'sync-status'], ['conversations'], ['media']];
|
||||
case 'reddit.session.changed':
|
||||
return [['reddit', 'session']];
|
||||
case 'log.created':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserEventSource(url: string): DashboardEventSource {
|
||||
const source = new EventSource(url);
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
source.addEventListener(type, (event) => listener(event));
|
||||
},
|
||||
close() {
|
||||
source.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
||||
import type { BotStats, Timers } from '../../api/contracts.js';
|
||||
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
|
||||
return (
|
||||
<article className="metric-card">
|
||||
<span>{label}</span>
|
||||
<strong data-metric={metric}>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TimersPanel({ timers }: { timers: Timers }) {
|
||||
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
|
||||
return (
|
||||
<section aria-labelledby="timers-heading" className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Rate limits</span>
|
||||
<h2 id="timers-heading">Sending window</h2>
|
||||
</div>
|
||||
<span className={isReady ? 'pill success' : 'pill warning'}>{isReady ? 'Ready' : 'Cooling down'}</span>
|
||||
</div>
|
||||
<dl className="definition-list">
|
||||
<div>
|
||||
<dt>Next message</dt>
|
||||
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Hourly allowance</dt>
|
||||
<dd>{timers.messagesRemaining} messages remaining</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Allowance resets</dt>
|
||||
<dd>{formatTime(timers.hourlyResetAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityPanel({ stats }: { stats: BotStats }) {
|
||||
return (
|
||||
<section aria-labelledby="activity-heading" className="panel activity-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Latest signals</span>
|
||||
<h2 id="activity-heading">Recent activity</h2>
|
||||
</div>
|
||||
</div>
|
||||
{stats.recentActivity.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No recent activity yet.</strong>
|
||||
<span>New bot events will appear here.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="activity-list">
|
||||
{stats.recentActivity.map((item) => (
|
||||
<li key={`${item.timestamp}-${item.message}`}>
|
||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||
<div><strong>{item.message}</strong><small>{formatTime(item.timestamp)}</small></div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const statsQuery = useStatsQuery();
|
||||
const timersQuery = useTimersQuery();
|
||||
const controlMutation = useSetRunningMutation();
|
||||
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
|
||||
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
|
||||
|
||||
async function refreshOverview() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.stats }),
|
||||
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.timers }),
|
||||
]);
|
||||
}
|
||||
|
||||
function confirmControl() {
|
||||
if (!confirmationAction) return;
|
||||
controlMutation.mutate(confirmationAction === 'start', {
|
||||
onSuccess: () => setConfirmationAction(null),
|
||||
});
|
||||
}
|
||||
|
||||
if (statsQuery.isPending) {
|
||||
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
|
||||
}
|
||||
|
||||
if (statsQuery.isError) {
|
||||
return (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Overview unavailable</strong>
|
||||
<span>{errorMessage(statsQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const action = stats.isRunning ? 'stop' : 'start';
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Control room</span>
|
||||
<h1>Overview</h1>
|
||||
<p>System health, sending limits, and the signals that need attention.</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
|
||||
className="button secondary"
|
||||
disabled={isRefreshing}
|
||||
onClick={() => void refreshOverview()}
|
||||
type="button"
|
||||
>
|
||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
|
||||
className={action === 'start' ? 'button primary' : 'button danger'}
|
||||
onClick={() => setConfirmationAction(action)}
|
||||
type="button"
|
||||
>
|
||||
{action === 'start' ? 'Start bot' : 'Stop bot'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Bot state" className="status-strip">
|
||||
<div>
|
||||
<span className={`status-dot ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
|
||||
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></div>
|
||||
</div>
|
||||
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
|
||||
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
|
||||
</section>
|
||||
|
||||
{stats.invitationCooldown ? (
|
||||
<div className="cooldown-banner" role="status">
|
||||
<strong>Reddit invitation cooldown</strong>
|
||||
<span>{stats.invitationCooldown.reason}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section aria-label="Key metrics" className="metrics-grid">
|
||||
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
|
||||
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
|
||||
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
|
||||
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
|
||||
</section>
|
||||
|
||||
<div className="overview-grid">
|
||||
{timersQuery.isPending ? (
|
||||
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
|
||||
) : timersQuery.isError ? (
|
||||
<section className="panel error-panel" role="alert">
|
||||
<strong>Sending window unavailable</strong>
|
||||
<span>{errorMessage(timersQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void timersQuery.refetch()} type="button">Retry timers</button>
|
||||
</section>
|
||||
) : (
|
||||
<TimersPanel timers={timersQuery.data} />
|
||||
)}
|
||||
<ActivityPanel stats={stats} />
|
||||
</div>
|
||||
|
||||
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
|
||||
{confirmationAction ? (
|
||||
<ConfirmationDialog
|
||||
action={confirmationAction}
|
||||
isPending={controlMutation.isPending}
|
||||
onCancel={() => setConfirmationAction(null)}
|
||||
onConfirm={confirmControl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DailyTrendResponseSchema,
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
SuccessfulExamplesResponseSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
|
||||
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
||||
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
||||
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
||||
|
||||
async function parseResponse<T>(
|
||||
response: Response,
|
||||
schema: { parse(value: unknown): T },
|
||||
): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
'error' in payload &&
|
||||
typeof payload.error === 'string'
|
||||
? payload.error
|
||||
: `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export interface PerformanceApi {
|
||||
getSubredditPerformance(): Promise<SubredditPerformanceResponse>;
|
||||
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
||||
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
||||
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
||||
}
|
||||
|
||||
export function createPerformanceApi(
|
||||
fetchImplementation?: typeof fetch,
|
||||
) {
|
||||
function request(path: string): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(path);
|
||||
}
|
||||
return {
|
||||
async getSubredditPerformance() {
|
||||
return parseResponse(
|
||||
await request('/api/analytics/by-subreddit'),
|
||||
SubredditPerformanceResponseSchema,
|
||||
);
|
||||
},
|
||||
async getStrategyPerformance() {
|
||||
return parseResponse(
|
||||
await request('/api/analytics/by-strategy'),
|
||||
StrategyPerformanceResponseSchema,
|
||||
);
|
||||
},
|
||||
async getDailyTrend(days: number) {
|
||||
return parseResponse(
|
||||
await request(`/api/analytics/trend?days=${days}`),
|
||||
DailyTrendResponseSchema,
|
||||
);
|
||||
},
|
||||
async getSuccessfulExamples() {
|
||||
return parseResponse(
|
||||
await request('/api/successful'),
|
||||
SuccessfulExamplesResponseSchema,
|
||||
);
|
||||
},
|
||||
} satisfies PerformanceApi;
|
||||
}
|
||||
|
||||
export const performanceApi = createPerformanceApi();
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { z } from 'zod';
|
||||
import type {
|
||||
DailyTrendResponseSchema,
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
SuccessfulExample,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
useDailyTrendQuery,
|
||||
useStrategyPerformanceQuery,
|
||||
useSubredditPerformanceQuery,
|
||||
useSuccessfulExamplesQuery,
|
||||
} from './performance-queries.js';
|
||||
import './performance.css';
|
||||
|
||||
const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
|
||||
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
||||
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
||||
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
|
||||
|
||||
function SampleSize({ totalSent }: { totalSent: number }) {
|
||||
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
|
||||
return (
|
||||
<span className="sample-size">
|
||||
n = {totalSent}
|
||||
{isLowConfidence ? <span className="confidence-warning">Low confidence</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceTable({
|
||||
label,
|
||||
rows,
|
||||
rowLabel,
|
||||
}: {
|
||||
label: string;
|
||||
rows: readonly (SubredditPerformance | StrategyPerformance)[];
|
||||
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
<table aria-label={label} className="performance-table">
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Rate</th><th scope="col">Sample</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={rowLabel(row)}>
|
||||
<th scope="row">
|
||||
{rowLabel(row)}
|
||||
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
||||
</th>
|
||||
<td>{row.totalResponses}</td>
|
||||
<td>{row.responseRate}%</td>
|
||||
<td><SampleSize totalSent={row.totalSent} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
|
||||
return (
|
||||
<ol aria-label="30 day response trend" className="trend-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.date}>
|
||||
<time dateTime={row.date}>{row.date}</time>
|
||||
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
|
||||
<strong>{row.responseRate}%</strong>
|
||||
<SampleSize totalSent={row.totalSent} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample[] }) {
|
||||
if (examples.length === 0) return <p className="feature-empty">No successful examples have been recorded yet.</p>;
|
||||
return (
|
||||
<div className="example-grid">
|
||||
{examples.map((example) => {
|
||||
const source = example.subreddit ?? 'unknown source';
|
||||
return (
|
||||
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
|
||||
<div><span className="pill success">Response received</span><small>{source}</small></div>
|
||||
{example.postTitle ? <h3>{example.postTitle}</h3> : null}
|
||||
<blockquote>{example.originalMessage}</blockquote>
|
||||
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PerformancePage() {
|
||||
const subreddits = useSubredditPerformanceQuery();
|
||||
const strategies = useStrategyPerformanceQuery();
|
||||
const trend = useDailyTrendQuery(30);
|
||||
const examples = useSuccessfulExamplesQuery();
|
||||
const queries = [subreddits, strategies, trend, examples];
|
||||
if (queries.some((query) => query.isPending)) {
|
||||
return <div className="page-state" role="status">Loading performance…</div>;
|
||||
}
|
||||
const failed = queries.find((query) => query.isError);
|
||||
if (failed?.error) {
|
||||
return <div className="page-state error-state" role="alert"><strong>Performance unavailable</strong><span>{failed.error.message}</span></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page performance-page">
|
||||
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response outcomes without hiding the sample sizes behind the percentages.</p></div></header>
|
||||
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
|
||||
<div className="performance-grid">
|
||||
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
|
||||
<section aria-labelledby="strategy-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div><PerformanceTable label="Strategy performance" rows={strategies.data ?? []} rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} /></section>
|
||||
</div>
|
||||
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { performanceApi } from './performance-api.js';
|
||||
|
||||
export const performanceQueryKeys = {
|
||||
subreddits: ['performance', 'subreddits'] as const,
|
||||
strategies: ['performance', 'strategies'] as const,
|
||||
trend: (days: number) => ['performance', 'trend', days] as const,
|
||||
successfulExamples: ['performance', 'successful-examples'] as const,
|
||||
};
|
||||
|
||||
export function useSubredditPerformanceQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.subreddits,
|
||||
queryFn: performanceApi.getSubredditPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStrategyPerformanceQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.strategies,
|
||||
queryFn: performanceApi.getStrategyPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDailyTrendQuery(days: number) {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.trend(days),
|
||||
queryFn: () => performanceApi.getDailyTrend(days),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSuccessfulExamplesQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.successfulExamples,
|
||||
queryFn: performanceApi.getSuccessfulExamples,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.performance-page { display: grid; gap: .8rem; }
|
||||
.feature-panel { min-width: 0; }
|
||||
.performance-grid { display: grid; gap: .8rem; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.performance-table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
.performance-table th, .performance-table td { padding: .7rem .5rem; border-top: 1px solid var(--border); }
|
||||
.performance-table thead th { border-top: 0; color: var(--muted); font-size: .75rem; text-transform: uppercase; }
|
||||
.performance-table tbody th { min-width: 9rem; }
|
||||
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
|
||||
.confidence-warning { color: var(--warning); }
|
||||
.sample-size { white-space: nowrap; color: var(--muted); }
|
||||
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
|
||||
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 6rem; align-items: center; gap: .75rem; }
|
||||
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
|
||||
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||
.example-grid { display: grid; gap: .75rem; }
|
||||
.successful-example { padding: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.successful-example > div { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.successful-example small { color: var(--muted); }
|
||||
.successful-example h3 { margin-bottom: .5rem; }
|
||||
.successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
|
||||
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
||||
.feature-empty { color: var(--muted); }
|
||||
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@@ -0,0 +1,15 @@
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<div><span className="eyebrow">Workspace</span><h1>{title}</h1><p>{description}</p></div>
|
||||
</header>
|
||||
<section className="panel empty-state"><strong>{title} workspace</strong><span>This route is ready for its feature module.</span></section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { AppProviders } from './app/providers.js';
|
||||
import { dashboardRouter } from './app/router.js';
|
||||
import './styles.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) throw new Error('Dashboard root element is missing');
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AppProviders>
|
||||
<RouterProvider router={dashboardRouter} />
|
||||
</AppProviders>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
:root {
|
||||
color: #e9f1ec;
|
||||
background: #09110e;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--surface: #101a16;
|
||||
--surface-raised: #17231e;
|
||||
--border: #2a3a33;
|
||||
--muted: #9aaca3;
|
||||
--accent: #75d69d;
|
||||
--accent-dark: #153c28;
|
||||
--danger: #ff8a84;
|
||||
--warning: #f3c76b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .6; }
|
||||
a { color: inherit; }
|
||||
|
||||
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: white; color: black; padding: .75rem 1rem; border-radius: .5rem; }
|
||||
.skip-link:focus { top: 1rem; }
|
||||
.app-shell { min-height: 100vh; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 17.5rem; padding: 1.5rem 1rem; visibility: hidden; background: #0c1511; border-right: 1px solid var(--border); transform: translateX(-100%); transition: transform 180ms ease, visibility 180ms; }
|
||||
.sidebar.is-open { visibility: visible; transform: translateX(0); }
|
||||
.brand { display: flex; align-items: center; gap: .75rem; padding: 0 .5rem 1.75rem; }
|
||||
.brand-mark { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; border-radius: .75rem; color: #07130c; background: var(--accent); font-weight: 900; }
|
||||
.brand div { display: grid; gap: .1rem; }
|
||||
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
|
||||
nav { display: grid; gap: .35rem; }
|
||||
.nav-link { display: grid; gap: .2rem; padding: .8rem .75rem; color: var(--muted); text-decoration: none; border-radius: .75rem; border: 1px solid transparent; }
|
||||
.nav-link span { color: #dfe9e3; font-weight: 650; }
|
||||
.nav-link small { font-size: .72rem; line-height: 1.3; }
|
||||
.nav-link:hover { background: var(--surface); }
|
||||
.nav-link.is-active { background: var(--accent-dark); border-color: #28593d; }
|
||||
.nav-link.is-active span { color: #b8f1cd; }
|
||||
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
|
||||
.workspace { min-height: 100vh; }
|
||||
.mobile-header { position: sticky; top: 0; z-index: 15; display: flex; align-items: center; justify-content: space-between; height: 4rem; padding: 0 1rem; background: rgba(9, 17, 14, .92); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); font-weight: 700; }
|
||||
.icon-button { width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: .6rem; color: white; background: var(--surface); font-size: 1.25rem; }
|
||||
main { min-height: calc(100vh - 4rem); }
|
||||
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||||
.page-header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1.5rem; margin-bottom: 1.5rem; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: .4rem; font-size: clamp(2rem, 4vw, 3rem); line-height: 1; letter-spacing: -.045em; }
|
||||
h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.page-header p { max-width: 42rem; margin-bottom: 0; color: var(--muted); }
|
||||
.eyebrow { display: block; margin-bottom: .45rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.header-actions, .dialog-actions { display: flex; gap: .75rem; }
|
||||
.button { min-height: 2.6rem; padding: .55rem .9rem; border-radius: .65rem; border: 1px solid transparent; color: white; font-weight: 700; }
|
||||
.button.primary { color: #06140b; background: var(--accent); }
|
||||
.button.secondary { border-color: var(--border); background: var(--surface-raised); }
|
||||
.button.danger { color: #2d0907; background: var(--danger); }
|
||||
.status-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; padding: 1rem; margin-bottom: 1rem; background: linear-gradient(110deg, #13221b, #0f1915); border: 1px solid var(--border); border-radius: .9rem; }
|
||||
.status-strip > div { display: flex; align-items: center; gap: .75rem; }
|
||||
.status-strip > div div { display: grid; }
|
||||
.status-strip small { color: var(--muted); }
|
||||
.status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
|
||||
.status-dot.running { background: var(--accent); box-shadow: 0 0 0 .35rem rgba(117, 214, 157, .12); }
|
||||
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
|
||||
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
||||
.pill.neutral { color: #d4dfda; background: #2b3832; }
|
||||
.pill.success { color: #aaf0c4; background: #163a26; }
|
||||
.pill.warning { color: #ffe1a0; background: #42351b; }
|
||||
.status-note { margin-left: auto; color: var(--muted); font-size: .85rem; }
|
||||
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .8rem; }
|
||||
.cooldown-banner span { font-size: .85rem; }
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
|
||||
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
|
||||
.metric-card > span, .metric-card small { color: var(--muted); }
|
||||
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
|
||||
.overview-grid { display: grid; gap: .75rem; }
|
||||
.panel { padding: 1rem; }
|
||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||||
.definition-list { display: grid; gap: .75rem; margin: 0; }
|
||||
.definition-list > div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .75rem; border-top: 1px solid var(--border); }
|
||||
.definition-list dt { color: var(--muted); }
|
||||
.definition-list dd { margin: 0; text-align: right; font-weight: 700; }
|
||||
.empty-state { display: grid; place-items: center; gap: .4rem; min-height: 10rem; text-align: center; color: var(--muted); }
|
||||
.empty-state strong { color: #e9f1ec; }
|
||||
.activity-list { display: grid; gap: .75rem; padding: 0; margin: 0; list-style: none; }
|
||||
.activity-list li { display: flex; align-items: flex-start; gap: .75rem; }
|
||||
.activity-list li div { display: grid; gap: .15rem; }
|
||||
.activity-list small { color: var(--muted); }
|
||||
.activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
|
||||
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
|
||||
.activity-marker.error { background: var(--danger); }
|
||||
.page-state { display: grid; place-items: center; align-content: center; gap: 1rem; min-height: 60vh; color: var(--muted); }
|
||||
.error-state strong, .error-panel strong { color: var(--danger); }
|
||||
.error-panel, .panel-loading { display: grid; place-items: center; align-content: center; gap: .75rem; min-height: 14rem; text-align: center; color: var(--muted); }
|
||||
.spinner { width: 1.4rem; height: 1.4rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.dialog-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .72); }
|
||||
.dialog-card { width: min(28rem, 100%); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
|
||||
.dialog-card p { color: var(--muted); }
|
||||
.dialog-actions { justify-content: flex-end; margin-top: 1.5rem; }
|
||||
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
|
||||
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.sidebar { visibility: visible; transform: none; }
|
||||
.workspace { margin-left: 17.5rem; }
|
||||
.mobile-header { display: none; }
|
||||
main { min-height: 100vh; }
|
||||
.page { padding: 3rem 2rem 5rem; }
|
||||
.navigation-backdrop { display: none; }
|
||||
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
+22
-1
@@ -54,7 +54,7 @@ if command -v fluxbox >/dev/null 2>&1; then
|
||||
fluxbox >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
VNC_ARGS=(-display "${DISPLAY}" -forever -shared -rfbport "${VNC_PORT}" -nopw_warn)
|
||||
VNC_ARGS=(-display "${DISPLAY}" -forever -shared -rfbport "${VNC_PORT}")
|
||||
if [ -n "${VNC_PASSWORD:-}" ]; then
|
||||
mkdir -p /root/.vnc
|
||||
x11vnc -storepasswd "${VNC_PASSWORD}" /root/.vnc/passwd >/dev/null
|
||||
@@ -68,6 +68,27 @@ fi
|
||||
x11vnc "${VNC_ARGS[@]}" >/var/log/x11vnc.log 2>&1 &
|
||||
X11VNC_PID=$!
|
||||
|
||||
VNC_READY=0
|
||||
for i in $(seq 1 50); do
|
||||
if ! kill -0 "${X11VNC_PID}" 2>/dev/null; then
|
||||
echo "[entrypoint] FATAL: x11vnc exited before becoming ready" >&2
|
||||
cat /var/log/x11vnc.log >&2
|
||||
exit 1
|
||||
fi
|
||||
if (: </dev/tcp/127.0.0.1/${VNC_PORT}) 2>/dev/null; then
|
||||
echo "[entrypoint] VNC server is ready after ${i} checks"
|
||||
VNC_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ "${VNC_READY}" != "1" ]; then
|
||||
echo "[entrypoint] FATAL: x11vnc did not listen on port ${VNC_PORT} after 10s" >&2
|
||||
cat /var/log/x11vnc.log >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[entrypoint] starting noVNC (websockify) on :${NOVNC_PORT}"
|
||||
websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \
|
||||
>/var/log/websockify.log 2>&1 &
|
||||
|
||||
@@ -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.
|
||||
Generated
+2571
-310
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -5,7 +5,11 @@
|
||||
"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",
|
||||
"start": "tsx src/index.ts",
|
||||
"login": "tsx scripts/login.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
@@ -21,6 +25,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"axios": "^1.13.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"dotenv": "^17.2.3",
|
||||
@@ -29,16 +34,28 @@
|
||||
"p-queue": "^9.0.1",
|
||||
"p-retry": "^7.1.1",
|
||||
"playwright": "^1.57.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"winston": "^3.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"jsdom": "^28.1.0",
|
||||
"msw": "^2.12.10",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@reddit-bot/api-contracts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ActivityTypeSchema = z.union([
|
||||
z.literal('sent'),
|
||||
z.literal('skipped'),
|
||||
z.literal('response'),
|
||||
z.literal('error'),
|
||||
z.literal('info'),
|
||||
z.literal('debug'),
|
||||
]);
|
||||
|
||||
export const ActivityItemSchema = z.strictObject({
|
||||
timestamp: z.number(),
|
||||
type: ActivityTypeSchema,
|
||||
message: z.string(),
|
||||
recipient: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ActivityResponseSchema = z.array(ActivityItemSchema);
|
||||
export type ActivityItem = z.infer<typeof ActivityItemSchema>;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js';
|
||||
|
||||
export const ChatMediaSchema = z.strictObject({
|
||||
originalUrl: z.string(),
|
||||
localPath: z.string(),
|
||||
fileName: z.string(),
|
||||
});
|
||||
export const ChatMessageSchema = z.strictObject({
|
||||
sender: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.number(),
|
||||
isSelf: z.boolean(),
|
||||
media: z.array(ChatMediaSchema).optional(),
|
||||
});
|
||||
export const ActiveChatSchema = z.strictObject({
|
||||
username: z.string(),
|
||||
roomName: z.string(),
|
||||
lastMessageTime: z.number(),
|
||||
lastMessageSnippet: z.string(),
|
||||
isFromUs: z.boolean(),
|
||||
unread: z.boolean(),
|
||||
url: z.string().optional(),
|
||||
roomId: z.string().optional(),
|
||||
messages: z.array(ChatMessageSchema).optional(),
|
||||
originMessage: z.string().optional(),
|
||||
});
|
||||
export const ChatsResponseSchema = z.array(ActiveChatSchema);
|
||||
export const ConversationsResponseSchema = ChatsResponseSchema;
|
||||
|
||||
export const SyncKindSchema = z.union([z.literal('full'), z.literal('delta')]);
|
||||
export const SyncPhaseSchema = z.union([
|
||||
z.literal('starting'),
|
||||
z.literal('seeding'),
|
||||
z.literal('listing-rooms'),
|
||||
z.literal('resolving-usernames'),
|
||||
z.literal('loading-details'),
|
||||
z.literal('finalizing'),
|
||||
z.literal('done'),
|
||||
]);
|
||||
export const SyncProgressSchema = z.strictObject({
|
||||
kind: SyncKindSchema,
|
||||
phase: SyncPhaseSchema,
|
||||
current: z.number(),
|
||||
total: z.number(),
|
||||
label: z.string().optional(),
|
||||
startedAt: z.number(),
|
||||
});
|
||||
export const ChatSyncStatusSchema = z.strictObject({
|
||||
syncing: z.boolean(),
|
||||
syncingKind: SyncKindSchema.nullable(),
|
||||
lastFullSyncAt: z.number().nullable(),
|
||||
lastDeltaSyncAt: z.number().nullable(),
|
||||
hasEverFullSynced: z.boolean(),
|
||||
cachedChats: z.number(),
|
||||
progress: SyncProgressSchema.nullable(),
|
||||
});
|
||||
|
||||
export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
|
||||
triggeringPost: z.strictObject({
|
||||
id: z.string(),
|
||||
subreddit: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
url: z.string(),
|
||||
sentAt: z.number(),
|
||||
}).nullable(),
|
||||
syncInProgress: z.strictObject({
|
||||
kind: SyncKindSchema.nullable(),
|
||||
progress: SyncProgressSchema.nullable(),
|
||||
staleMessages: z.boolean(),
|
||||
}).nullable(),
|
||||
});
|
||||
export const SuggestedReplyResponseSchema = z.strictObject({ suggestedReply: z.string() });
|
||||
export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) });
|
||||
export const StartConversationRequestSchema = z.strictObject({
|
||||
username: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
});
|
||||
export const StartConversationResponseSchema = z.strictObject({
|
||||
success: z.literal(true),
|
||||
chat: ActiveChatSchema,
|
||||
});
|
||||
export const ChatSyncTriggerResponseSchema = z.union([
|
||||
z.strictObject({ success: z.literal(true), kind: SyncKindSchema }),
|
||||
RejectedActionResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
]);
|
||||
export const FetchAllChatsResponseSchema = z.strictObject({
|
||||
success: z.literal(true),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
export type ActiveChat = z.infer<typeof ActiveChatSchema>;
|
||||
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
|
||||
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ErrorResponseSchema = z.strictObject({
|
||||
error: z.string(),
|
||||
detail: z.unknown().optional(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
||||
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
|
||||
export type ControlResponse = z.infer<typeof ControlResponseSchema>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ToneKeySchema = z.union([
|
||||
z.literal('default'),
|
||||
z.literal('dominant'),
|
||||
z.literal('submissive'),
|
||||
z.literal('playful'),
|
||||
z.literal('direct'),
|
||||
z.literal('gentle'),
|
||||
z.literal('kink-friendly'),
|
||||
z.literal('teasing'),
|
||||
]);
|
||||
|
||||
export const RateLimitConfigSchema = z.strictObject({
|
||||
minDelayBetweenMessagesMs: z.number(),
|
||||
maxMessagesPerHour: z.number(),
|
||||
jitterMs: z.number(),
|
||||
});
|
||||
export const RuntimeRedditConfigSchema = z.strictObject({
|
||||
prioritizeRecentPosts: z.boolean(),
|
||||
recentWindowMinutes: z.number(),
|
||||
});
|
||||
export const RuntimeConfigSchema = z.strictObject({
|
||||
rateLimit: RateLimitConfigSchema,
|
||||
browser: z.strictObject({ dryRun: z.boolean() }),
|
||||
reddit: RuntimeRedditConfigSchema,
|
||||
});
|
||||
export const SubredditConfigSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
type: z.union([z.literal('image'), z.literal('chat')]),
|
||||
weight: z.number().optional(),
|
||||
tone: ToneKeySchema,
|
||||
scripted: z.string().optional(),
|
||||
});
|
||||
export const ConfigResponseSchema = RuntimeConfigSchema.extend({
|
||||
subreddits: z.array(SubredditConfigSchema),
|
||||
tones: z.array(ToneKeySchema),
|
||||
});
|
||||
export const ConfigUpdateRequestSchema = z.strictObject({
|
||||
rateLimit: RateLimitConfigSchema.partial().optional(),
|
||||
browser: z.strictObject({ dryRun: z.boolean().optional() }).optional(),
|
||||
reddit: RuntimeRedditConfigSchema.partial().optional(),
|
||||
subreddits: z.array(SubredditConfigSchema).min(1).optional(),
|
||||
});
|
||||
export const ConfigUpdateResponseSchema = z.strictObject({
|
||||
success: z.literal(true),
|
||||
config: RuntimeConfigSchema,
|
||||
subreddits: z.array(SubredditConfigSchema),
|
||||
});
|
||||
|
||||
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
|
||||
export type ConfigResponse = z.infer<typeof ConfigResponseSchema>;
|
||||
export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { z } from 'zod';
|
||||
import { SyncProgressSchema } from './chats.js';
|
||||
|
||||
const EventBase = {
|
||||
id: z.string(),
|
||||
occurredAt: z.string().datetime({ offset: true }),
|
||||
};
|
||||
|
||||
export const BotRuntimeStatusSchema = z.union([
|
||||
z.literal('starting'),
|
||||
z.literal('running'),
|
||||
z.literal('stopping'),
|
||||
z.literal('stopped'),
|
||||
]);
|
||||
export const ConversationStatusSchema = z.union([
|
||||
z.literal('new'),
|
||||
z.literal('awaiting-reply'),
|
||||
z.literal('replied'),
|
||||
z.literal('closed'),
|
||||
]);
|
||||
export const RealtimeSyncKindSchema = z.union([
|
||||
z.literal('media'),
|
||||
z.literal('messages'),
|
||||
z.literal('conversations'),
|
||||
z.literal('full'),
|
||||
]);
|
||||
export const RealtimeRedditSessionStatusSchema = z.union([
|
||||
z.literal('unknown'),
|
||||
z.literal('authenticating'),
|
||||
z.literal('authenticated'),
|
||||
z.literal('expired'),
|
||||
z.literal('logged-out'),
|
||||
z.literal('error'),
|
||||
]);
|
||||
|
||||
export const DashboardEventSchema = z.discriminatedUnion('type', [
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('bot.status.changed'),
|
||||
payload: z.strictObject({
|
||||
status: BotRuntimeStatusSchema,
|
||||
dryRun: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('message.sent'),
|
||||
payload: z.strictObject({
|
||||
messageId: z.string(),
|
||||
recipient: z.string(),
|
||||
conversationId: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('message.failed'),
|
||||
payload: z.strictObject({
|
||||
recipient: z.string(),
|
||||
reason: z.string(),
|
||||
messageId: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('conversation.updated'),
|
||||
payload: z.strictObject({
|
||||
conversationId: z.string(),
|
||||
status: ConversationStatusSchema,
|
||||
unreadCount: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('reply.received'),
|
||||
payload: z.strictObject({
|
||||
conversationId: z.string(),
|
||||
replyId: z.string(),
|
||||
sender: z.string(),
|
||||
preview: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('sync.started'),
|
||||
payload: z.strictObject({
|
||||
syncId: z.string(),
|
||||
kind: RealtimeSyncKindSchema,
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('sync.progress'),
|
||||
payload: z.strictObject({
|
||||
syncId: z.string(),
|
||||
kind: RealtimeSyncKindSchema,
|
||||
completed: z.number(),
|
||||
total: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('sync.completed'),
|
||||
payload: z.strictObject({
|
||||
syncId: z.string(),
|
||||
kind: RealtimeSyncKindSchema,
|
||||
processed: z.number(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('sync.failed'),
|
||||
payload: z.strictObject({
|
||||
syncId: z.string(),
|
||||
kind: RealtimeSyncKindSchema,
|
||||
reason: z.string(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('rate-limit.changed'),
|
||||
payload: z.strictObject({
|
||||
limited: z.boolean(),
|
||||
remaining: z.number().optional(),
|
||||
resetsAt: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('reddit.session.changed'),
|
||||
payload: z.strictObject({
|
||||
status: RealtimeRedditSessionStatusSchema,
|
||||
username: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
...EventBase,
|
||||
type: z.literal('log.created'),
|
||||
payload: z.strictObject({
|
||||
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
|
||||
message: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Existing `/api/logs/stream` frames, retained for an incremental migration. */
|
||||
export const LegacyDashboardStreamEventSchema = z.discriminatedUnion('type', [
|
||||
z.strictObject({
|
||||
type: z.literal('connected'),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.literal('log'),
|
||||
timestamp: z.number(),
|
||||
level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]),
|
||||
message: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.literal('sync-progress'),
|
||||
progress: SyncProgressSchema.nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type DashboardEvent = z.infer<typeof DashboardEventSchema>;
|
||||
export type LegacyDashboardStreamEvent = z.infer<typeof LegacyDashboardStreamEventSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './activity.js';
|
||||
export * from './chats.js';
|
||||
export * from './common.js';
|
||||
export * from './config.js';
|
||||
export * from './events.js';
|
||||
export * from './media.js';
|
||||
export * from './messages.js';
|
||||
export * from './session.js';
|
||||
export * from './stats.js';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
import { RejectedActionResponseSchema, SuccessResponseSchema } from './common.js';
|
||||
|
||||
export const MediaItemSchema = z.strictObject({
|
||||
id: z.string(),
|
||||
chatUsername: z.string().nullable(),
|
||||
chatRoomId: z.string().nullable(),
|
||||
sender: z.string(),
|
||||
timestamp: z.number(),
|
||||
originalUrl: z.string(),
|
||||
fileName: z.string(),
|
||||
relativePath: z.string(),
|
||||
createdAt: z.number(),
|
||||
urlPath: z.string(),
|
||||
});
|
||||
export const MediaResponseSchema = z.array(MediaItemSchema);
|
||||
export const MediaSyncStatusSchema = z.strictObject({
|
||||
isSyncing: z.boolean(),
|
||||
lastSyncAt: z.number().nullable(),
|
||||
lastSyncDurationMs: z.number().nullable(),
|
||||
lastSyncChats: z.number().nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
nextSyncAt: z.number().nullable(),
|
||||
});
|
||||
export const MediaSyncRequestSchema = z.strictObject({ full: z.boolean().optional() });
|
||||
export const MediaSyncTriggerResponseSchema = z.union([
|
||||
SuccessResponseSchema,
|
||||
RejectedActionResponseSchema,
|
||||
]);
|
||||
|
||||
export type MediaItem = z.infer<typeof MediaItemSchema>;
|
||||
export type MediaSyncStatus = z.infer<typeof MediaSyncStatusSchema>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SentMessageSchema = z.strictObject({
|
||||
id: z.number(),
|
||||
postId: z.string().nullable(),
|
||||
recipient: z.string(),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
sentAt: z.number(),
|
||||
gotResponse: z.boolean(),
|
||||
responseCheckedAt: z.number().nullable(),
|
||||
strategyId: z.string().nullable(),
|
||||
chatRoomId: z.string().nullable(),
|
||||
subreddit: z.string().optional(),
|
||||
postTitle: z.string().optional(),
|
||||
postImageUrl: z.string().optional(),
|
||||
postImageAnalysis: z.string().optional(),
|
||||
});
|
||||
export const MessagesResponseSchema = z.array(SentMessageSchema);
|
||||
|
||||
export const SuccessfulExampleSchema = z.strictObject({
|
||||
id: z.number(),
|
||||
sentMessageId: z.number().nullable(),
|
||||
originalMessage: z.string(),
|
||||
imageAnalysis: z.string().nullable(),
|
||||
postTitle: z.string().nullable(),
|
||||
responseSnippet: z.string().nullable(),
|
||||
subreddit: z.string().nullable(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema);
|
||||
|
||||
export type SentMessage = z.infer<typeof SentMessageSchema>;
|
||||
export type SuccessfulExample = z.infer<typeof SuccessfulExampleSchema>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RedditSessionStateSchema = z.union([
|
||||
z.literal('unknown'),
|
||||
z.literal('authenticated'),
|
||||
z.literal('login-required'),
|
||||
z.literal('unreachable'),
|
||||
]);
|
||||
export const LoginInfoResponseSchema = z.strictObject({
|
||||
vncEnabled: z.boolean(),
|
||||
novncPort: z.number(),
|
||||
publicUrl: z.string().nullable(),
|
||||
passwordRequired: z.boolean(),
|
||||
});
|
||||
export const LoginStatusResponseSchema = z.union([
|
||||
z.strictObject({ ready: z.literal(false), error: z.string() }),
|
||||
z.strictObject({
|
||||
ready: z.literal(true),
|
||||
loggedIn: z.boolean(),
|
||||
status: RedditSessionStateSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
ready: z.literal(true),
|
||||
loggedIn: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
export const LoginStartResponseSchema = z.union([
|
||||
z.strictObject({ ok: z.literal(true) }),
|
||||
z.strictObject({ ok: z.literal(false), error: z.string() }),
|
||||
]);
|
||||
|
||||
export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
|
||||
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
import { ActivityItemSchema } from './activity.js';
|
||||
|
||||
const PerformanceFields = {
|
||||
totalSent: z.number(),
|
||||
totalResponses: z.number(),
|
||||
responseRate: z.number(),
|
||||
};
|
||||
|
||||
export const StatsResponseSchema = z.strictObject({
|
||||
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.strictObject({
|
||||
name: z.string(),
|
||||
type: z.union([z.literal('image'), z.literal('chat')]),
|
||||
})),
|
||||
targetGenders: z.array(z.string()),
|
||||
recentActivity: z.array(ActivityItemSchema),
|
||||
invitationCooldown: z.strictObject({
|
||||
until: z.number(),
|
||||
reason: z.string(),
|
||||
redditCode: z.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export const TimersResponseSchema = z.strictObject({
|
||||
nextMessageAvailableAt: z.number().nullable(),
|
||||
hourlyResetAt: z.number(),
|
||||
isHourlyLimitReached: z.boolean(),
|
||||
messagesRemaining: z.number(),
|
||||
});
|
||||
|
||||
export const SubredditPerformanceSchema = z.strictObject({
|
||||
subreddit: z.string(),
|
||||
...PerformanceFields,
|
||||
});
|
||||
export const SubredditPerformanceResponseSchema = z.array(SubredditPerformanceSchema);
|
||||
|
||||
export const StrategyPerformanceSchema = z.strictObject({
|
||||
strategyId: z.string(),
|
||||
...PerformanceFields,
|
||||
configured: z.boolean().optional(),
|
||||
});
|
||||
export const StrategyPerformanceResponseSchema = z.array(StrategyPerformanceSchema);
|
||||
|
||||
export const DailyTrendSchema = z.strictObject({
|
||||
date: z.string(),
|
||||
...PerformanceFields,
|
||||
});
|
||||
export const DailyTrendResponseSchema = z.array(DailyTrendSchema);
|
||||
|
||||
export type StatsResponse = z.infer<typeof StatsResponseSchema>;
|
||||
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ActivityResponseSchema,
|
||||
ChatSyncStatusSchema,
|
||||
ChatsResponseSchema,
|
||||
ChatSyncTriggerResponseSchema,
|
||||
ConfigResponseSchema,
|
||||
ConfigUpdateRequestSchema,
|
||||
ControlResponseSchema,
|
||||
ConversationDetailResponseSchema,
|
||||
ConversationsResponseSchema,
|
||||
DashboardEventSchema,
|
||||
ErrorResponseSchema,
|
||||
LoginInfoResponseSchema,
|
||||
LoginStatusResponseSchema,
|
||||
LegacyDashboardStreamEventSchema,
|
||||
MediaResponseSchema,
|
||||
MediaSyncStatusSchema,
|
||||
MessagesResponseSchema,
|
||||
StatsResponseSchema,
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
DailyTrendResponseSchema,
|
||||
SuccessfulExamplesResponseSchema,
|
||||
TimersResponseSchema,
|
||||
} from '../src/index.js';
|
||||
|
||||
const activity = {
|
||||
timestamp: 1_700_000_000_000,
|
||||
type: 'sent',
|
||||
message: 'Sent message',
|
||||
recipient: 'alice',
|
||||
};
|
||||
|
||||
const chat = {
|
||||
username: 'alice',
|
||||
roomName: 'Alice',
|
||||
lastMessageTime: 1_700_000_000_000,
|
||||
lastMessageSnippet: 'hello',
|
||||
isFromUs: false,
|
||||
unread: true,
|
||||
roomId: '!room:reddit.com',
|
||||
messages: [{ sender: 'alice', text: 'hello', timestamp: 1_700_000_000_000, isSelf: false }],
|
||||
};
|
||||
|
||||
describe('dashboard endpoint contracts', () => {
|
||||
it('accepts current stats and rejects malformed stats', () => {
|
||||
const payload = {
|
||||
isRunning: true,
|
||||
dryRunMode: false,
|
||||
totalProcessed: 10,
|
||||
totalMessagesSent: 8,
|
||||
totalResponses: 3,
|
||||
responseRate: 38,
|
||||
messagesThisHour: 2,
|
||||
maxMessagesPerHour: 8,
|
||||
subreddits: [{ name: 'example', type: 'image' }],
|
||||
targetGenders: ['female'],
|
||||
recentActivity: [activity],
|
||||
invitationCooldown: null,
|
||||
};
|
||||
expect(StatsResponseSchema.parse(payload)).toEqual(payload);
|
||||
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,
|
||||
hourlyResetAt: 1_700_000_100_000,
|
||||
isHourlyLimitReached: false,
|
||||
messagesRemaining: 6,
|
||||
}).success).toBe(true);
|
||||
expect(ActivityResponseSchema.safeParse([activity]).success).toBe(true);
|
||||
expect(MessagesResponseSchema.safeParse([{
|
||||
id: 1,
|
||||
postId: 'post-1',
|
||||
recipient: 'alice',
|
||||
subject: 'Hi',
|
||||
message: 'Hello',
|
||||
sentAt: 1_700_000_000_000,
|
||||
gotResponse: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: null,
|
||||
chatRoomId: null,
|
||||
}]).success).toBe(true);
|
||||
expect(SuccessfulExamplesResponseSchema.safeParse([{
|
||||
id: 1,
|
||||
sentMessageId: null,
|
||||
originalMessage: 'Hello',
|
||||
imageAnalysis: null,
|
||||
postTitle: null,
|
||||
responseSnippet: 'Hi',
|
||||
subreddit: 'example',
|
||||
createdAt: 1_700_000_000_000,
|
||||
}]).success).toBe(true);
|
||||
expect(TimersResponseSchema.safeParse({ hourlyResetAt: 'later' }).success).toBe(false);
|
||||
expect(ActivityResponseSchema.safeParse([{ ...activity, type: 'bogus' }]).success).toBe(false);
|
||||
expect(MessagesResponseSchema.safeParse([{ id: '1' }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates each analytics response shape', () => {
|
||||
expect(SubredditPerformanceResponseSchema.safeParse([{
|
||||
subreddit: 'example', totalSent: 10, totalResponses: 4, responseRate: 40,
|
||||
}]).success).toBe(true);
|
||||
expect(StrategyPerformanceResponseSchema.safeParse([{
|
||||
strategyId: 'direct', totalSent: 10, totalResponses: 4, responseRate: 40, configured: true,
|
||||
}]).success).toBe(true);
|
||||
expect(DailyTrendResponseSchema.safeParse([{
|
||||
date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40,
|
||||
}]).success).toBe(true);
|
||||
expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates runtime config returned by the dashboard', () => {
|
||||
const payload = {
|
||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||||
browser: { dryRun: false },
|
||||
reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60 },
|
||||
subreddits: [{ name: 'example', type: 'chat', weight: 2, tone: 'direct' }],
|
||||
tones: ['default', 'direct'],
|
||||
};
|
||||
expect(ConfigResponseSchema.parse(payload)).toEqual(payload);
|
||||
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
|
||||
expect(ConfigUpdateRequestSchema.safeParse({
|
||||
rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true },
|
||||
}).success).toBe(true);
|
||||
expect(ConfigUpdateRequestSchema.safeParse({ browser: { dryRun: 'yes' } }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates chats, sync state, conversation detail, and malformed messages', () => {
|
||||
expect(ChatsResponseSchema.safeParse([chat]).success).toBe(true);
|
||||
expect(ConversationsResponseSchema.safeParse([chat]).success).toBe(true);
|
||||
expect(ChatSyncStatusSchema.safeParse({
|
||||
syncing: true,
|
||||
syncingKind: 'full',
|
||||
lastFullSyncAt: null,
|
||||
lastDeltaSyncAt: 1_700_000_000_000,
|
||||
hasEverFullSynced: false,
|
||||
cachedChats: 12,
|
||||
progress: {
|
||||
kind: 'full', phase: 'loading-details', current: 3, total: 12,
|
||||
label: 'alice', startedAt: 1_700_000_000_000,
|
||||
},
|
||||
}).success).toBe(true);
|
||||
expect(ConversationDetailResponseSchema.safeParse({
|
||||
...chat,
|
||||
triggeringPost: {
|
||||
id: 'post-1', subreddit: 'example', title: 'Example',
|
||||
url: 'https://www.reddit.com/r/example/comments/post-1/', sentAt: 1_700_000_000_000,
|
||||
},
|
||||
syncInProgress: null,
|
||||
}).success).toBe(true);
|
||||
expect(ChatsResponseSchema.safeParse([{ ...chat, messages: [{ sender: 'alice' }] }]).success).toBe(false);
|
||||
expect(ChatSyncTriggerResponseSchema.safeParse({ success: true, kind: 'delta' }).success).toBe(true);
|
||||
expect(ChatSyncTriggerResponseSchema.safeParse({ success: false, reason: 'Already running full sync' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates media items and media sync status', () => {
|
||||
expect(MediaResponseSchema.safeParse([{
|
||||
id: 'sha1', chatUsername: 'alice', chatRoomId: '!room:reddit.com', sender: 'alice',
|
||||
timestamp: 1_700_000_000_000, originalUrl: 'https://example.com/a.jpg', fileName: 'a.jpg',
|
||||
relativePath: 'a.jpg', createdAt: 1_700_000_000_100, urlPath: '/media/a.jpg',
|
||||
}]).success).toBe(true);
|
||||
expect(MediaSyncStatusSchema.safeParse({
|
||||
isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null,
|
||||
lastSyncChats: null, lastError: null, nextSyncAt: null,
|
||||
}).success).toBe(true);
|
||||
expect(MediaResponseSchema.safeParse([{ id: 'sha1', timestamp: 'now' }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates login info and every current login-status branch', () => {
|
||||
expect(LoginInfoResponseSchema.safeParse({
|
||||
vncEnabled: true, novncPort: 6080, publicUrl: null, passwordRequired: true,
|
||||
}).success).toBe(true);
|
||||
expect(LoginStatusResponseSchema.safeParse({
|
||||
ready: true, loggedIn: true, status: 'authenticated',
|
||||
}).success).toBe(true);
|
||||
expect(LoginStatusResponseSchema.safeParse({ ready: false, error: 'browser not initialised' }).success).toBe(true);
|
||||
expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: false, error: 'check failed' }).success).toBe(true);
|
||||
expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: true, status: 'expired' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates the common error response and typed realtime event envelope', () => {
|
||||
expect(ErrorResponseSchema.safeParse({ error: 'Conversation not found' }).success).toBe(true);
|
||||
const occurredAt = '2026-08-08T13:55:00.000Z';
|
||||
const events = [
|
||||
{ id: '1', occurredAt, type: 'bot.status.changed', payload: { status: 'running', dryRun: false, reason: 'startup complete' } },
|
||||
{ id: '2', occurredAt, type: 'message.sent', payload: { messageId: 'm1', recipient: 'alice', conversationId: 'c1' } },
|
||||
{ id: '3', occurredAt, type: 'message.failed', payload: { recipient: 'alice', reason: 'rate limited', messageId: 'm2', conversationId: 'c1', code: 'limited', retryable: true } },
|
||||
{ id: '4', occurredAt, type: 'conversation.updated', payload: { conversationId: 'c1', status: 'replied', unreadCount: 1 } },
|
||||
{ id: '5', occurredAt, type: 'reply.received', payload: { conversationId: 'c1', replyId: 'r1', sender: 'alice', preview: 'Hi' } },
|
||||
{ id: '6', occurredAt, type: 'sync.started', payload: { syncId: 's1', kind: 'media' } },
|
||||
{ id: '7', occurredAt, type: 'sync.progress', payload: { syncId: 's1', kind: 'media', completed: 3, total: 10, message: 'Downloading' } },
|
||||
{ id: '8', occurredAt, type: 'sync.completed', payload: { syncId: 's1', kind: 'media', processed: 10 } },
|
||||
{ id: '9', occurredAt, type: 'sync.failed', payload: { syncId: 's1', kind: 'media', reason: 'network', code: 'offline' } },
|
||||
{ id: '10', occurredAt, type: 'rate-limit.changed', payload: { limited: true, remaining: 0, resetsAt: occurredAt, reason: 'hourly' } },
|
||||
{ id: '11', occurredAt, type: 'reddit.session.changed', payload: { status: 'authenticated', username: 'bot-user', reason: 'login completed' } },
|
||||
{ id: '12', occurredAt, type: 'log.created', payload: { level: 'info', message: 'Started', data: { port: 3000 } } },
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
expect(DashboardEventSchema.safeParse(event).success, event.type).toBe(true);
|
||||
}
|
||||
expect(DashboardEventSchema.safeParse({
|
||||
id: 'event-13', type: 'log.created', occurredAt: 1_700_000_000_000,
|
||||
payload: { level: 'info', message: 'Started' },
|
||||
}).success).toBe(false);
|
||||
expect(DashboardEventSchema.safeParse({
|
||||
id: 'event-14', type: 'sync.completed', occurredAt,
|
||||
payload: { syncId: 's1', kind: 'media' },
|
||||
}).success).toBe(false);
|
||||
expect(LegacyDashboardStreamEventSchema.safeParse({
|
||||
type: 'connected', timestamp: 1_700_000_000_000,
|
||||
}).success).toBe(true);
|
||||
expect(LegacyDashboardStreamEventSchema.safeParse({
|
||||
type: 'sync-progress', progress: null,
|
||||
}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -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,35 @@
|
||||
export interface EventClock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export interface EventIdGenerator {
|
||||
next(occurredAt: Date): string;
|
||||
}
|
||||
|
||||
/** The subset of Winston's logger contract used by the event hub. */
|
||||
export interface EventHubLogger {
|
||||
warn(message: string, metadata?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export const systemEventClock: EventClock = {
|
||||
now: () => new Date(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates lexically sortable IDs and remains monotonic if the wall clock
|
||||
* moves backwards. The per-process sequence disambiguates events in one ms.
|
||||
*/
|
||||
export class MonotonicEventIdGenerator implements EventIdGenerator {
|
||||
private lastTimestamp = 0;
|
||||
private sequence = 0;
|
||||
|
||||
next(occurredAt: Date): string {
|
||||
const timestamp = Math.max(occurredAt.getTime(), this.lastTimestamp);
|
||||
this.sequence = timestamp === this.lastTimestamp ? this.sequence + 1 : 0;
|
||||
this.lastTimestamp = timestamp;
|
||||
|
||||
return `${timestamp.toString().padStart(13, '0')}-${this.sequence
|
||||
.toString()
|
||||
.padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export type BotRuntimeStatus = 'starting' | 'running' | 'stopping' | 'stopped';
|
||||
|
||||
export type ConversationStatus =
|
||||
| 'new'
|
||||
| 'awaiting-reply'
|
||||
| 'replied'
|
||||
| 'closed';
|
||||
|
||||
export type SyncKind = 'media' | 'messages' | 'conversations' | 'full';
|
||||
|
||||
export type RedditSessionStatus =
|
||||
| 'unknown'
|
||||
| 'authenticating'
|
||||
| 'authenticated'
|
||||
| 'expired'
|
||||
| 'logged-out'
|
||||
| 'error';
|
||||
|
||||
export interface RealtimeEventMap {
|
||||
'bot.status.changed': {
|
||||
status: BotRuntimeStatus;
|
||||
dryRun: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
'message.sent': {
|
||||
messageId: string;
|
||||
recipient: string;
|
||||
conversationId?: string;
|
||||
};
|
||||
'message.failed': {
|
||||
recipient: string;
|
||||
reason: string;
|
||||
messageId?: string;
|
||||
conversationId?: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
'conversation.updated': {
|
||||
conversationId: string;
|
||||
status: ConversationStatus;
|
||||
unreadCount?: number;
|
||||
};
|
||||
'reply.received': {
|
||||
conversationId: string;
|
||||
replyId: string;
|
||||
sender: string;
|
||||
preview?: string;
|
||||
};
|
||||
'sync.started': {
|
||||
syncId: string;
|
||||
kind: SyncKind;
|
||||
};
|
||||
'sync.progress': {
|
||||
syncId: string;
|
||||
kind: SyncKind;
|
||||
completed: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
};
|
||||
'sync.completed': {
|
||||
syncId: string;
|
||||
kind: SyncKind;
|
||||
processed: number;
|
||||
};
|
||||
'sync.failed': {
|
||||
syncId: string;
|
||||
kind: SyncKind;
|
||||
reason: string;
|
||||
code?: string;
|
||||
};
|
||||
'rate-limit.changed': {
|
||||
limited: boolean;
|
||||
remaining?: number;
|
||||
resetsAt?: string;
|
||||
reason?: string;
|
||||
};
|
||||
'reddit.session.changed': {
|
||||
status: RedditSessionStatus;
|
||||
username?: string;
|
||||
reason?: string;
|
||||
};
|
||||
'log.created': {
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type RealtimeEventType = keyof RealtimeEventMap;
|
||||
|
||||
/** A distributive type so the default is a discriminated union. */
|
||||
export type RealtimeEvent<
|
||||
TType extends RealtimeEventType = RealtimeEventType,
|
||||
> = TType extends RealtimeEventType
|
||||
? Readonly<{
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
type: TType;
|
||||
payload: Readonly<RealtimeEventMap[TType]>;
|
||||
}>
|
||||
: never;
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { RequestHandler, Response } from 'express';
|
||||
import {
|
||||
systemEventClock,
|
||||
type EventClock,
|
||||
} from './event-dependencies.js';
|
||||
import type { RealtimeEvent } from './event-types.js';
|
||||
import type {
|
||||
RealtimeEventHub,
|
||||
RealtimeEventSubscription,
|
||||
} from './realtime-event-hub.js';
|
||||
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
const DEFAULT_MAX_PENDING_FRAMES = 100;
|
||||
|
||||
export interface RecurringTask {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface RecurringTaskScheduler {
|
||||
every(intervalMs: number, callback: () => void): RecurringTask;
|
||||
}
|
||||
|
||||
export const systemRecurringTaskScheduler: RecurringTaskScheduler = {
|
||||
every(intervalMs, callback) {
|
||||
const timer = setInterval(callback, intervalMs);
|
||||
timer.unref();
|
||||
return { cancel: () => clearInterval(timer) };
|
||||
},
|
||||
};
|
||||
|
||||
export interface ExpressSseHandlerOptions {
|
||||
hub: RealtimeEventHub;
|
||||
scheduler?: RecurringTaskScheduler;
|
||||
clock?: EventClock;
|
||||
heartbeatIntervalMs?: number;
|
||||
maxPendingFrames?: number;
|
||||
}
|
||||
|
||||
function isResponseClosed(response: Response): boolean {
|
||||
return response.destroyed || response.writableEnded;
|
||||
}
|
||||
|
||||
function serializeEvent(event: RealtimeEvent): string {
|
||||
const safeId = event.id.replace(/[\r\n\0]/g, '');
|
||||
return `id: ${safeId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts the transport-agnostic realtime hub to an Express SSE endpoint.
|
||||
* The returned handler owns only the lifetime of its individual connection.
|
||||
*/
|
||||
export function createExpressSseHandler(
|
||||
options: ExpressSseHandlerOptions,
|
||||
): RequestHandler {
|
||||
const heartbeatIntervalMs =
|
||||
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
const maxPendingFrames =
|
||||
options.maxPendingFrames ?? DEFAULT_MAX_PENDING_FRAMES;
|
||||
if (!Number.isInteger(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {
|
||||
throw new RangeError('heartbeatIntervalMs must be a positive integer');
|
||||
}
|
||||
if (!Number.isInteger(maxPendingFrames) || maxPendingFrames <= 0) {
|
||||
throw new RangeError('maxPendingFrames must be a positive integer');
|
||||
}
|
||||
|
||||
const scheduler = options.scheduler ?? systemRecurringTaskScheduler;
|
||||
const clock = options.clock ?? systemEventClock;
|
||||
|
||||
return (request, response): void => {
|
||||
if (isResponseClosed(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let waitingForDrain = false;
|
||||
let subscription: RealtimeEventSubscription | undefined;
|
||||
let heartbeat: RecurringTask | undefined;
|
||||
const pendingFrames: string[] = [];
|
||||
|
||||
const removeConnectionListeners = (): void => {
|
||||
request.removeListener('close', handleConnectionClose);
|
||||
response.removeListener('close', handleConnectionClose);
|
||||
response.removeListener('drain', drainPendingFrames);
|
||||
};
|
||||
|
||||
const close = (endResponse: boolean): void => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
waitingForDrain = false;
|
||||
pendingFrames.length = 0;
|
||||
removeConnectionListeners();
|
||||
heartbeat?.cancel();
|
||||
subscription?.unsubscribe();
|
||||
if (endResponse && !isResponseClosed(response)) {
|
||||
try {
|
||||
response.end();
|
||||
} catch {
|
||||
// The socket is already unusable; all owned resources are released.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionClose = (): void => close(false);
|
||||
|
||||
const waitForDrain = (): void => {
|
||||
if (active && !waitingForDrain) {
|
||||
waitingForDrain = true;
|
||||
response.once('drain', drainPendingFrames);
|
||||
}
|
||||
};
|
||||
|
||||
const writeFrame = (frame: string): boolean => {
|
||||
if (!active || isResponseClosed(response)) {
|
||||
close(false);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!response.write(frame)) {
|
||||
waitForDrain();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
close(true);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const enqueueOrWrite = (frame: string): void => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
if (!waitingForDrain) {
|
||||
writeFrame(frame);
|
||||
return;
|
||||
}
|
||||
if (pendingFrames.length >= maxPendingFrames) {
|
||||
close(true);
|
||||
return;
|
||||
}
|
||||
pendingFrames.push(frame);
|
||||
};
|
||||
|
||||
function drainPendingFrames(): void {
|
||||
if (!active || isResponseClosed(response)) {
|
||||
close(false);
|
||||
return;
|
||||
}
|
||||
waitingForDrain = false;
|
||||
while (active && !waitingForDrain && pendingFrames.length > 0) {
|
||||
const frame = pendingFrames.shift();
|
||||
if (frame !== undefined) {
|
||||
writeFrame(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.once('close', handleConnectionClose);
|
||||
response.once('close', handleConnectionClose);
|
||||
|
||||
try {
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
response.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
response.setHeader('Connection', 'keep-alive');
|
||||
response.setHeader('X-Accel-Buffering', 'no');
|
||||
response.flushHeaders();
|
||||
} catch {
|
||||
close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
enqueueOrWrite(': connected\n\n');
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
heartbeat = scheduler.every(heartbeatIntervalMs, () => {
|
||||
if (!active || waitingForDrain) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
enqueueOrWrite(`: heartbeat ${clock.now().toISOString()}\n\n`);
|
||||
} catch {
|
||||
close(true);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
close(true);
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
heartbeat.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
subscription = options.hub.subscribe(
|
||||
(event) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
enqueueOrWrite(serializeEvent(event));
|
||||
} catch {
|
||||
close(true);
|
||||
}
|
||||
},
|
||||
{ lastEventId: request.get('Last-Event-ID') },
|
||||
);
|
||||
} catch {
|
||||
close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Replay is synchronous, so a failed replay can close before assignment.
|
||||
if (!active) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
MonotonicEventIdGenerator,
|
||||
systemEventClock,
|
||||
type EventClock,
|
||||
type EventHubLogger,
|
||||
type EventIdGenerator,
|
||||
} from './event-dependencies.js';
|
||||
export {
|
||||
type BotRuntimeStatus,
|
||||
type ConversationStatus,
|
||||
type RealtimeEvent,
|
||||
type RealtimeEventMap,
|
||||
type RealtimeEventType,
|
||||
type RedditSessionStatus,
|
||||
type SyncKind,
|
||||
} from './event-types.js';
|
||||
export {
|
||||
RealtimeEventHub,
|
||||
type RealtimeEventHubOptions,
|
||||
type RealtimeEventListener,
|
||||
type RealtimeEventSubscription,
|
||||
type SubscribeOptions,
|
||||
} from './realtime-event-hub.js';
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
MonotonicEventIdGenerator,
|
||||
systemEventClock,
|
||||
type EventClock,
|
||||
type EventHubLogger,
|
||||
type EventIdGenerator,
|
||||
} from './event-dependencies.js';
|
||||
import {
|
||||
type RealtimeEvent,
|
||||
type RealtimeEventMap,
|
||||
type RealtimeEventType,
|
||||
} from './event-types.js';
|
||||
import { ReplayBuffer, type Sequenced } from './replay-buffer.js';
|
||||
|
||||
export type RealtimeEventListener = (event: RealtimeEvent) => void;
|
||||
|
||||
export interface SubscribeOptions {
|
||||
/** Value from the SSE `Last-Event-ID` request header. */
|
||||
lastEventId?: string;
|
||||
}
|
||||
|
||||
export interface RealtimeEventSubscription {
|
||||
readonly closed: boolean;
|
||||
unsubscribe(): void;
|
||||
}
|
||||
|
||||
export interface RealtimeEventHubOptions {
|
||||
capacity: number;
|
||||
clock?: EventClock;
|
||||
idGenerator?: EventIdGenerator;
|
||||
logger?: EventHubLogger;
|
||||
}
|
||||
|
||||
interface SubscriberState {
|
||||
listener: RealtimeEventListener;
|
||||
closed: boolean;
|
||||
replaying: boolean;
|
||||
lastDeliveredSequence: number;
|
||||
queued: Sequenced<RealtimeEvent>[];
|
||||
}
|
||||
|
||||
class HubSubscription implements RealtimeEventSubscription {
|
||||
constructor(
|
||||
private readonly state: SubscriberState,
|
||||
private readonly remove: () => void,
|
||||
) {}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.state.closed;
|
||||
}
|
||||
|
||||
unsubscribe(): void {
|
||||
if (this.state.closed) {
|
||||
return;
|
||||
}
|
||||
this.state.closed = true;
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
export class RealtimeEventHub {
|
||||
private readonly buffer: ReplayBuffer<RealtimeEvent>;
|
||||
private readonly clock: EventClock;
|
||||
private readonly idGenerator: EventIdGenerator;
|
||||
private readonly logger?: EventHubLogger;
|
||||
private readonly subscribers = new Set<SubscriberState>();
|
||||
private readonly dispatchQueue: Sequenced<RealtimeEvent>[] = [];
|
||||
private sequence = 0;
|
||||
private dispatching = false;
|
||||
private isClosed = false;
|
||||
|
||||
constructor(options: RealtimeEventHubOptions) {
|
||||
this.buffer = new ReplayBuffer(options.capacity);
|
||||
this.clock = options.clock ?? systemEventClock;
|
||||
this.idGenerator = options.idGenerator ?? new MonotonicEventIdGenerator();
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.isClosed;
|
||||
}
|
||||
|
||||
publish<TType extends RealtimeEventType>(
|
||||
type: TType,
|
||||
payload: RealtimeEventMap[TType],
|
||||
): RealtimeEvent<TType> {
|
||||
this.assertOpen();
|
||||
const occurredAt = this.clock.now();
|
||||
if (Number.isNaN(occurredAt.getTime())) {
|
||||
throw new RangeError('clock returned an invalid date');
|
||||
}
|
||||
|
||||
const id = this.idGenerator.next(occurredAt);
|
||||
if (id.length === 0) {
|
||||
throw new Error('idGenerator returned an empty event ID');
|
||||
}
|
||||
|
||||
const event = Object.freeze({
|
||||
id,
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
type,
|
||||
payload: Object.freeze({ ...payload }),
|
||||
}) as RealtimeEvent<TType>;
|
||||
const sequenced: Sequenced<RealtimeEvent> = {
|
||||
sequence: ++this.sequence,
|
||||
value: event,
|
||||
};
|
||||
this.buffer.push(sequenced);
|
||||
this.dispatchQueue.push(sequenced);
|
||||
this.drainDispatchQueue();
|
||||
return event;
|
||||
}
|
||||
|
||||
subscribe(
|
||||
listener: RealtimeEventListener,
|
||||
options: SubscribeOptions = {},
|
||||
): RealtimeEventSubscription {
|
||||
this.assertOpen();
|
||||
const state: SubscriberState = {
|
||||
listener,
|
||||
closed: false,
|
||||
replaying: true,
|
||||
lastDeliveredSequence: 0,
|
||||
queued: [],
|
||||
};
|
||||
this.subscribers.add(state);
|
||||
const subscription = new HubSubscription(state, () => {
|
||||
this.subscribers.delete(state);
|
||||
state.queued = [];
|
||||
});
|
||||
|
||||
for (const event of this.buffer.after(options.lastEventId)) {
|
||||
this.deliver(state, event);
|
||||
if (state.closed) {
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
|
||||
state.replaying = false;
|
||||
const queued = state.queued;
|
||||
state.queued = [];
|
||||
for (const event of queued) {
|
||||
this.deliver(state, event);
|
||||
if (state.closed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return subscription;
|
||||
}
|
||||
|
||||
recent(limit = this.buffer.capacity): readonly RealtimeEvent[] {
|
||||
if (!Number.isInteger(limit) || limit < 0) {
|
||||
throw new RangeError('limit must be a non-negative integer');
|
||||
}
|
||||
return limit === 0 ? [] : this.buffer.recent(limit);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.isClosed) {
|
||||
return;
|
||||
}
|
||||
this.isClosed = true;
|
||||
for (const state of this.subscribers) {
|
||||
state.closed = true;
|
||||
state.queued = [];
|
||||
}
|
||||
this.subscribers.clear();
|
||||
this.dispatchQueue.length = 0;
|
||||
this.buffer.clear();
|
||||
}
|
||||
|
||||
private drainDispatchQueue(): void {
|
||||
if (this.dispatching) {
|
||||
return;
|
||||
}
|
||||
this.dispatching = true;
|
||||
try {
|
||||
let next: Sequenced<RealtimeEvent> | undefined;
|
||||
while ((next = this.dispatchQueue.shift()) !== undefined) {
|
||||
for (const state of [...this.subscribers]) {
|
||||
if (state.replaying) {
|
||||
state.queued.push(next);
|
||||
} else {
|
||||
this.deliver(state, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.dispatching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private deliver(
|
||||
state: SubscriberState,
|
||||
event: Sequenced<RealtimeEvent>,
|
||||
): void {
|
||||
if (state.closed || event.sequence <= state.lastDeliveredSequence) {
|
||||
return;
|
||||
}
|
||||
state.lastDeliveredSequence = event.sequence;
|
||||
try {
|
||||
state.listener(event.value);
|
||||
} catch (error: unknown) {
|
||||
state.closed = true;
|
||||
state.queued = [];
|
||||
this.subscribers.delete(state);
|
||||
try {
|
||||
this.logger?.warn(
|
||||
'Realtime event subscriber failed; subscription closed',
|
||||
{ eventId: event.value.id, error },
|
||||
);
|
||||
} catch {
|
||||
// A diagnostic logger must not break event delivery to healthy clients.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.isClosed) {
|
||||
throw new Error('RealtimeEventHub is closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface Sequenced<TValue> {
|
||||
readonly sequence: number;
|
||||
readonly value: TValue;
|
||||
}
|
||||
|
||||
export class ReplayBuffer<TValue extends { readonly id: string }> {
|
||||
private values: Sequenced<TValue>[] = [];
|
||||
|
||||
constructor(readonly capacity: number) {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new RangeError('capacity must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
push(value: Sequenced<TValue>): void {
|
||||
this.values.push(value);
|
||||
if (this.values.length > this.capacity) {
|
||||
this.values.shift();
|
||||
}
|
||||
}
|
||||
|
||||
after(lastEventId?: string): readonly Sequenced<TValue>[] {
|
||||
if (lastEventId === undefined || lastEventId.length === 0) {
|
||||
return [...this.values];
|
||||
}
|
||||
|
||||
const matchingIndex = this.values.findIndex(
|
||||
({ value }) => value.id === lastEventId,
|
||||
);
|
||||
return matchingIndex === -1
|
||||
? [...this.values]
|
||||
: this.values.slice(matchingIndex + 1);
|
||||
}
|
||||
|
||||
recent(limit: number): readonly TValue[] {
|
||||
return this.values.slice(-limit).map(({ value }) => value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values = [];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import type { LogEntry } from './types.js';
|
||||
import { RealtimeEventHub } from './events/index.js';
|
||||
import { createExpressSseHandler } from './events/express-sse-adapter.js';
|
||||
|
||||
const MAX_LOGS = 500;
|
||||
const INITIAL_LOGS_ON_CONNECT = 50;
|
||||
@@ -11,9 +13,24 @@ const INITIAL_LOGS_ON_CONNECT = 50;
|
||||
export class LogBroadcaster {
|
||||
private entries: LogEntry[] = [];
|
||||
private clients = new Set<Response>();
|
||||
private readonly eventHub: RealtimeEventHub;
|
||||
readonly events: RequestHandler;
|
||||
|
||||
constructor(eventHub?: RealtimeEventHub) {
|
||||
this.eventHub = eventHub ?? new RealtimeEventHub({ capacity: MAX_LOGS });
|
||||
this.events = createExpressSseHandler({ hub: this.eventHub });
|
||||
}
|
||||
|
||||
push(level: LogEntry['level'], message: string, data?: unknown): void {
|
||||
const entry: LogEntry = { timestamp: Date.now(), level, message, data };
|
||||
const realtimeEvent = this.eventHub.closed
|
||||
? undefined
|
||||
: this.eventHub.publish('log.created', { level, message, data });
|
||||
const entry: LogEntry = {
|
||||
timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(),
|
||||
level,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > MAX_LOGS) {
|
||||
this.entries = this.entries.slice(-MAX_LOGS);
|
||||
@@ -76,5 +93,6 @@ export class LogBroadcaster {
|
||||
}
|
||||
}
|
||||
this.clients.clear();
|
||||
this.eventHub.close();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { WebContext } from '../context.js';
|
||||
export function createLogsRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
router.get('/logs/stream', ctx.broadcaster.stream);
|
||||
router.get('/events/stream', ctx.broadcaster.events);
|
||||
router.get('/logs', ctx.broadcaster.recent);
|
||||
return router;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
|
||||
|
||||
interface TestStatsOverrides {
|
||||
isRunning?: boolean;
|
||||
recentActivity?: readonly unknown[];
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
function createStats(overrides: TestStatsOverrides = {}) {
|
||||
return {
|
||||
isRunning: overrides.isRunning ?? false,
|
||||
dryRunMode: true,
|
||||
totalProcessed: 18,
|
||||
totalMessagesSent: 12,
|
||||
totalResponses: 3,
|
||||
responseRate: 25,
|
||||
messagesThisHour: 2,
|
||||
maxMessagesPerHour: 8,
|
||||
subreddits: [{ name: 'casualconversation', type: 'chat' }],
|
||||
targetGenders: ['female'],
|
||||
recentActivity: overrides.recentActivity ?? [],
|
||||
invitationCooldown: null,
|
||||
};
|
||||
}
|
||||
|
||||
function installSuccessfulApi(overrides: TestStatsOverrides = {}) {
|
||||
server.use(
|
||||
http.get('/api/stats', () => HttpResponse.json(createStats(overrides))),
|
||||
http.get('/api/timers', () =>
|
||||
HttpResponse.json({
|
||||
nextMessageAvailableAt: null,
|
||||
hourlyResetAt: Date.now() + 3_600_000,
|
||||
isHourlyLimitReached: false,
|
||||
messagesRemaining: 6,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderDashboard(initialPath = '/') {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const router = createMemoryRouter(createDashboardRoutes(), { initialEntries: [initialPath] });
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return { queryClient, router };
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('dashboard foundation', () => {
|
||||
it('provides responsive navigation with real routes for every platform area', async () => {
|
||||
installSuccessfulApi();
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDashboard('/');
|
||||
|
||||
const navigation = screen.getByRole('navigation', { name: 'Primary navigation' });
|
||||
for (const label of ['Overview', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
|
||||
expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
|
||||
expect(screen.getByRole('button', { name: 'Close navigation' })).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
await user.click(within(navigation).getByRole('link', { name: 'Operations' }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/operations'));
|
||||
expect(screen.getByRole('heading', { name: 'Operations' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading then renders stats, timers, and an explicit empty activity state', async () => {
|
||||
let resolveStats: ((response: Response) => void) | undefined;
|
||||
server.use(
|
||||
http.get('/api/stats', async () =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveStats = resolve;
|
||||
}),
|
||||
),
|
||||
http.get('/api/timers', () =>
|
||||
HttpResponse.json({
|
||||
nextMessageAvailableAt: null,
|
||||
hourlyResetAt: Date.now() + 3_600_000,
|
||||
isHourlyLimitReached: false,
|
||||
messagesRemaining: 6,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
renderDashboard();
|
||||
expect(screen.getByRole('status', { name: 'Loading overview' })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(resolveStats).toBeTypeOf('function'));
|
||||
resolveStats?.(HttpResponse.json(createStats()));
|
||||
|
||||
expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
|
||||
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
|
||||
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dry run')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
|
||||
let timerRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/stats', () => HttpResponse.json(createStats())),
|
||||
http.get('/api/timers', () => {
|
||||
timerRequests += 1;
|
||||
if (timerRequests === 1) return HttpResponse.json({ error: 'Rate limiter not initialized' }, { status: 503 });
|
||||
return HttpResponse.json({
|
||||
nextMessageAvailableAt: null,
|
||||
hourlyResetAt: Date.now() + 3_600_000,
|
||||
isHourlyLimitReached: false,
|
||||
messagesRemaining: 6,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Rate limiter not initialized');
|
||||
|
||||
await user.click(within(alert).getByRole('button', { name: 'Retry timers' }));
|
||||
expect(await screen.findByText('6 messages remaining')).toBeInTheDocument();
|
||||
expect(timerRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('shows refresh progress while preserving the current overview', async () => {
|
||||
installSuccessfulApi();
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
const refreshButton = await screen.findByRole('button', { name: 'Refresh overview' });
|
||||
let resolveRefresh: ((response: Response) => void) | undefined;
|
||||
server.use(
|
||||
http.get('/api/stats', async () =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await user.click(refreshButton);
|
||||
expect(screen.getByRole('button', { name: 'Refreshing overview' })).toBeDisabled();
|
||||
expect(screen.getByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
|
||||
|
||||
resolveRefresh?.(HttpResponse.json(createStats()));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh overview' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('requires confirmation before starting and reflects the authoritative response', async () => {
|
||||
installSuccessfulApi();
|
||||
let startRequests = 0;
|
||||
server.use(
|
||||
http.post('/api/start', () => {
|
||||
startRequests += 1;
|
||||
return HttpResponse.json({ success: true, running: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Start bot' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Start bot?' });
|
||||
expect(dialog).toHaveTextContent('The bot will resume automated processing.');
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
expect(startRequests).toBe(0);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Start bot' }));
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm start' }));
|
||||
|
||||
expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument();
|
||||
expect(startRequests).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
||||
|
||||
interface ChatFixtureOptions {
|
||||
username: string;
|
||||
roomId: string;
|
||||
unread?: boolean;
|
||||
isFromUs?: boolean;
|
||||
lastMessageSnippet?: string;
|
||||
}
|
||||
|
||||
function chatFixture(options: ChatFixtureOptions) {
|
||||
return {
|
||||
username: options.username,
|
||||
roomName: options.username,
|
||||
roomId: options.roomId,
|
||||
url: `https://chat.reddit.com/room/${options.roomId}`,
|
||||
lastMessageTime: 1_786_000_000_000,
|
||||
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
|
||||
isFromUs: options.isFromUs ?? false,
|
||||
unread: options.unread ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const alice = chatFixture({ username: 'alice', roomId: 'room-alice', unread: true });
|
||||
const bob = chatFixture({ username: 'bob', roomId: 'room-bob', isFromUs: true });
|
||||
|
||||
function detailFixture(chat = alice) {
|
||||
return {
|
||||
...chat,
|
||||
messages: [
|
||||
{ sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true },
|
||||
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
|
||||
],
|
||||
triggeringPost: {
|
||||
id: 'post-1',
|
||||
subreddit: 'casualconversation',
|
||||
title: 'Looking for a chat',
|
||||
url: 'https://reddit.com/r/casualconversation/comments/post-1',
|
||||
sentAt: 1_785_999_800_000,
|
||||
},
|
||||
syncInProgress: null,
|
||||
};
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
function installConversationList() {
|
||||
server.use(
|
||||
http.get('/api/conversations', () => HttpResponse.json([alice, bob])),
|
||||
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
|
||||
);
|
||||
}
|
||||
|
||||
function renderConversations() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('ConversationsPage', () => {
|
||||
it('shows loading and a clear empty state', async () => {
|
||||
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
|
||||
renderConversations();
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Loading conversations' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('No conversations yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('recovers from a list error without replacing the page', async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get('/api/conversations', () => {
|
||||
requests += 1;
|
||||
if (requests === 1) return HttpResponse.json({ error: 'Chat service unavailable' }, { status: 503 });
|
||||
return HttpResponse.json([alice]);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Chat service unavailable');
|
||||
await user.click(within(alert).getByRole('button', { name: 'Retry conversations' }));
|
||||
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches and filters the master list, then opens conversation detail', async () => {
|
||||
installConversationList();
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
|
||||
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
|
||||
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
|
||||
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'unread');
|
||||
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
|
||||
});
|
||||
|
||||
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
|
||||
installConversationList();
|
||||
let sendRequests = 0;
|
||||
server.use(
|
||||
http.post('/api/conversations/room-alice/suggest-reply', () =>
|
||||
HttpResponse.json({ suggestedReply: 'That sounds lovely, what happened next?' }),
|
||||
),
|
||||
http.post('/api/conversations/room-alice/send-reply', async ({ request }) => {
|
||||
sendRequests += 1;
|
||||
const body = await request.json() as { message: string };
|
||||
expect(body.message).toBe('My edited reply');
|
||||
return HttpResponse.json({ error: 'Reddit is temporarily unavailable' }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||
await screen.findByRole('heading', { name: 'u/alice' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
|
||||
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
||||
expect(composer).toHaveValue('That sounds lovely, what happened next?');
|
||||
await user.clear(composer);
|
||||
await user.type(composer, 'My edited reply');
|
||||
|
||||
const sendButton = screen.getByRole('button', { name: 'Review and send reply' });
|
||||
await user.click(sendButton);
|
||||
expect(screen.getByRole('dialog', { name: 'Send this reply?' })).toHaveTextContent('My edited reply');
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(sendButton).toHaveFocus();
|
||||
expect(sendRequests).toBe(0);
|
||||
|
||||
await user.click(sendButton);
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Reddit is temporarily unavailable');
|
||||
expect(composer).toHaveValue('My edited reply');
|
||||
|
||||
server.use(
|
||||
http.post('/api/conversations/room-alice/send-reply', () => {
|
||||
sendRequests += 1;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
await user.click(sendButton);
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
|
||||
expect(await screen.findByRole('status', { name: 'Reply sent' })).toHaveTextContent('Reply sent to u/alice');
|
||||
expect(composer).toHaveValue('');
|
||||
expect(sendRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
|
||||
installConversationList();
|
||||
let startRequests = 0;
|
||||
server.use(
|
||||
http.post('/api/conversations/start', () => {
|
||||
startRequests += 1;
|
||||
return HttpResponse.json({ error: 'Invitation cooldown active' }, { status: 429 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New conversation' }));
|
||||
const username = screen.getByRole('textbox', { name: 'Reddit username' });
|
||||
const message = screen.getByRole('textbox', { name: 'Opening message' });
|
||||
await user.type(username, 'charlie');
|
||||
await user.type(message, 'Hello Charlie');
|
||||
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Invitation cooldown active');
|
||||
expect(username).toHaveValue('charlie');
|
||||
expect(message).toHaveValue('Hello Charlie');
|
||||
|
||||
server.use(
|
||||
http.post('/api/conversations/start', () => {
|
||||
startRequests += 1;
|
||||
return HttpResponse.json({ success: true, chat: chatFixture({ username: 'charlie', roomId: 'room-charlie' }) });
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
|
||||
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie');
|
||||
expect(username).toHaveValue('');
|
||||
expect(message).toHaveValue('');
|
||||
expect(startRequests).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createLiveConsoleState,
|
||||
reduceLiveConsoleState,
|
||||
selectVisibleConsoleEntries,
|
||||
} from '../../apps/dashboard/src/features/operations/live-console-model.js';
|
||||
|
||||
function entry(id: string, level: 'debug' | 'info' | 'warn' | 'error', message: string) {
|
||||
return { id, occurredAt: '2026-08-08T12:00:00.000Z', level, message };
|
||||
}
|
||||
|
||||
describe('live console state', () => {
|
||||
it('bounds entries, rejects duplicate IDs, and filters by level and search', () => {
|
||||
let state = createLiveConsoleState(2);
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'Sync started') });
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'error', 'Matrix failed') });
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'error', 'duplicate') });
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('3', 'warn', 'Sync slow') });
|
||||
state = reduceLiveConsoleState(state, { type: 'set-level', level: 'warn' });
|
||||
state = reduceLiveConsoleState(state, { type: 'set-search', search: 'sync' });
|
||||
|
||||
expect(state.entries.map(({ id }) => id)).toEqual(['2', '3']);
|
||||
expect(selectVisibleConsoleEntries(state).map(({ id }) => id)).toEqual(['3']);
|
||||
});
|
||||
|
||||
it('holds bounded pending entries while paused and merges them on resume', () => {
|
||||
let state = createLiveConsoleState(2);
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'one') });
|
||||
state = reduceLiveConsoleState(state, { type: 'set-paused', paused: true });
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'info', 'two') });
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('3', 'info', 'three') });
|
||||
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('4', 'info', 'four') });
|
||||
|
||||
expect(state.entries.map(({ id }) => id)).toEqual(['1']);
|
||||
expect(state.pendingEntries.map(({ id }) => id)).toEqual(['3', '4']);
|
||||
|
||||
state = reduceLiveConsoleState(state, { type: 'set-paused', paused: false });
|
||||
expect(state.entries.map(({ id }) => id)).toEqual(['3', '4']);
|
||||
expect(state.pendingEntries).toEqual([]);
|
||||
});
|
||||
|
||||
it('deduplicates the same log delivered by the initial snapshot and SSE replay', () => {
|
||||
let state = createLiveConsoleState();
|
||||
state = reduceLiveConsoleState(state, {
|
||||
type: 'append',
|
||||
entry: entry('initial-1', 'info', 'Database initialized'),
|
||||
});
|
||||
state = reduceLiveConsoleState(state, {
|
||||
type: 'append',
|
||||
entry: entry('realtime-1', 'info', 'Database initialized'),
|
||||
});
|
||||
|
||||
expect(state.entries).toHaveLength(1);
|
||||
expect(state.entries[0]?.id).toBe('initial-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaPage } from '../../apps/dashboard/src/features/media/media-page.js';
|
||||
|
||||
const now = Date.now();
|
||||
const mediaItems = [
|
||||
{
|
||||
id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice',
|
||||
timestamp: now - 60_000, originalUrl: 'https://media.example/one.jpg', fileName: 'one.jpg',
|
||||
relativePath: 'one.jpg', createdAt: now - 50_000, urlPath: '/media/one.jpg',
|
||||
},
|
||||
{
|
||||
id: 'two', chatUsername: 'bob', chatRoomId: 'room-b', sender: 'charlie',
|
||||
timestamp: now - (3 * 86_400_000), originalUrl: 'https://media.example/two.gif', fileName: 'two.gif',
|
||||
relativePath: 'two.gif', createdAt: now - (3 * 86_400_000), urlPath: '/media/two.gif',
|
||||
},
|
||||
{
|
||||
id: 'three', chatUsername: null, chatRoomId: 'room-c', sender: 'dave',
|
||||
timestamp: now - (40 * 86_400_000), originalUrl: 'https://media.example/three.png', fileName: 'three.png',
|
||||
relativePath: 'three.png', createdAt: now - (40 * 86_400_000), urlPath: '/media/three.png',
|
||||
},
|
||||
];
|
||||
|
||||
const idleStatus = {
|
||||
isSyncing: false,
|
||||
lastSyncAt: now - 120_000,
|
||||
lastSyncDurationMs: 2_400,
|
||||
lastSyncChats: 3,
|
||||
lastError: null,
|
||||
nextSyncAt: now + 1_800_000,
|
||||
};
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
function installMediaApi(items = mediaItems) {
|
||||
server.use(
|
||||
http.get('/api/media', () => HttpResponse.json(items)),
|
||||
http.get('/api/media/sync', () => HttpResponse.json(idleStatus)),
|
||||
);
|
||||
}
|
||||
|
||||
function renderMedia() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MediaPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, queryClient };
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('media page', () => {
|
||||
it('loads authenticated media, keeps it blurred, and supports item and session-local reveals', async () => {
|
||||
let resolveMedia: ((response: Response) => void) | undefined;
|
||||
const credentials: RequestCredentials[] = [];
|
||||
server.use(
|
||||
http.get('/api/media', async ({ request }) => {
|
||||
credentials.push(request.credentials);
|
||||
return new Promise<Response>((resolve) => { resolveMedia = resolve; });
|
||||
}),
|
||||
http.get('/api/media/sync', ({ request }) => {
|
||||
credentials.push(request.credentials);
|
||||
return HttpResponse.json(idleStatus);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const firstView = renderMedia();
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Loading media' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
|
||||
resolveMedia?.(HttpResponse.json(mediaItems));
|
||||
|
||||
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
|
||||
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
|
||||
expect(aliceImage).toHaveClass('is-blurred');
|
||||
expect(charlieImage).toHaveClass('is-blurred');
|
||||
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal media from alice' }));
|
||||
expect(aliceImage).not.toHaveClass('is-blurred');
|
||||
expect(charlieImage).toHaveClass('is-blurred');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
|
||||
expect(charlieImage).not.toHaveClass('is-blurred');
|
||||
|
||||
firstView.unmount();
|
||||
installMediaApi();
|
||||
renderMedia();
|
||||
expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred');
|
||||
});
|
||||
|
||||
it('filters by conversation, sender, media type, and age with an explicit no-match state', async () => {
|
||||
installMediaApi();
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
expect(await screen.findAllByRole('img')).toHaveLength(3);
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1);
|
||||
expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
|
||||
expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1);
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'all');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Date range' }), '7d');
|
||||
expect(screen.getAllByRole('img')).toHaveLength(2);
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'room-c');
|
||||
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty and recoverable error states', async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get('/api/media', () => {
|
||||
requests += 1;
|
||||
return requests === 1
|
||||
? HttpResponse.json({ error: 'Media index unavailable' }, { status: 500 })
|
||||
: HttpResponse.json([]);
|
||||
}),
|
||||
http.get('/api/media/sync', () => HttpResponse.json(idleStatus)),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Media index unavailable');
|
||||
await user.click(within(alert).getByRole('button', { name: 'Retry media' }));
|
||||
expect(await screen.findByText('No media yet.')).toBeInTheDocument();
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
it('runs delta sync as the primary action and surfaces query-driven status and mutation outcomes', async () => {
|
||||
installMediaApi();
|
||||
let deltaRequests = 0;
|
||||
server.use(
|
||||
http.post('/api/media/sync', ({ request }) => {
|
||||
deltaRequests += 1;
|
||||
expect(request.credentials).toBe('same-origin');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
const deltaButton = await screen.findByRole('button', { name: 'Sync new media' });
|
||||
expect(deltaButton).toHaveClass('primary');
|
||||
expect(screen.getByText('Last synced 3 conversations')).toBeInTheDocument();
|
||||
await user.click(deltaButton);
|
||||
|
||||
expect(await screen.findByText('Media sync completed.')).toBeInTheDocument();
|
||||
expect(deltaRequests).toBe(1);
|
||||
|
||||
server.use(
|
||||
http.post('/api/media/sync', () => HttpResponse.json({ success: false, reason: 'Sync already in progress' })),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Sync new media' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
|
||||
});
|
||||
|
||||
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
|
||||
let statusRequests = 0;
|
||||
let resolveSync: ((response: Response) => void) | undefined;
|
||||
server.use(
|
||||
http.get('/api/media', () => HttpResponse.json(mediaItems)),
|
||||
http.get('/api/media/sync', () => {
|
||||
statusRequests += 1;
|
||||
return HttpResponse.json(statusRequests === 1 ? idleStatus : { ...idleStatus, isSyncing: true });
|
||||
}),
|
||||
http.post('/api/media/sync', async () =>
|
||||
new Promise<Response>((resolve) => { resolveSync = resolve; }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Sync new media' }));
|
||||
expect(await screen.findByText('Media sync in progress')).toBeInTheDocument();
|
||||
expect(statusRequests).toBeGreaterThan(1);
|
||||
|
||||
resolveSync?.(HttpResponse.json({ success: true }));
|
||||
await waitFor(() => expect(screen.getByText('Media sync completed.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('guards a full sync with explicit confirmation', async () => {
|
||||
installMediaApi();
|
||||
let fullRequests = 0;
|
||||
server.use(
|
||||
http.post('/api/media/sync/full', () => {
|
||||
fullRequests += 1;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Full sync' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Run full media sync?' });
|
||||
expect(dialog).toHaveTextContent('every conversation');
|
||||
expect(fullRequests).toBe(0);
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
expect(fullRequests).toBe(0);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Full sync' }));
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Run full sync' }));
|
||||
expect(await screen.findByText('Full media sync completed.')).toBeInTheDocument();
|
||||
expect(fullRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('closes the full-sync dialog so a failed mutation is clearly visible', async () => {
|
||||
installMediaApi();
|
||||
server.use(
|
||||
http.post('/api/media/sync/full', () =>
|
||||
HttpResponse.json({ error: 'Full sync could not reach Reddit' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderMedia();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Full sync' }));
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Run full sync' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Full sync could not reach Reddit');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
|
||||
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
function TestProviders({ children }: PropsWithChildren) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function renderPage(element: ReactElement) {
|
||||
return render(element, { wrapper: TestProviders });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('PerformancePage', () => {
|
||||
it('shows sample sizes, confidence warnings, useful comparisons, and successful examples', async () => {
|
||||
server.use(
|
||||
http.get('/api/analytics/by-subreddit', () =>
|
||||
HttpResponse.json([
|
||||
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30 },
|
||||
{ subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67 },
|
||||
]),
|
||||
),
|
||||
http.get('/api/analytics/by-strategy', () =>
|
||||
HttpResponse.json([
|
||||
{ strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, configured: true },
|
||||
{ strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50 },
|
||||
]),
|
||||
),
|
||||
http.get('/api/analytics/trend', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('days')).toBe('30');
|
||||
return HttpResponse.json([
|
||||
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
||||
{ date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50 },
|
||||
]);
|
||||
}),
|
||||
http.get('/api/successful', () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
id: 7,
|
||||
sentMessageId: 22,
|
||||
originalMessage: 'A thoughtful opener',
|
||||
imageAnalysis: null,
|
||||
postTitle: 'Weekend plans',
|
||||
responseSnippet: 'That sounds lovely!',
|
||||
subreddit: 'askuk',
|
||||
createdAt: Date.parse('2026-08-08T10:00:00Z'),
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
renderPage(<PerformancePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
||||
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
|
||||
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
||||
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
||||
expect(within(subredditTable).getByText('Low confidence')).toBeInTheDocument();
|
||||
const strategyTable = screen.getByRole('table', { name: 'Strategy performance' });
|
||||
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
|
||||
expect(within(strategyTable).getByText('Configured')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
|
||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
|
||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OperationsPage', () => {
|
||||
it('keeps readable activity, job state, and raw searchable logs separate', async () => {
|
||||
server.use(
|
||||
http.get('/api/activity', () =>
|
||||
HttpResponse.json([
|
||||
{ timestamp: Date.parse('2026-08-08T12:00:00Z'), type: 'response', message: 'Reply from u/alex', recipient: 'alex' },
|
||||
]),
|
||||
),
|
||||
http.get('/api/logs', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
|
||||
return HttpResponse.json([
|
||||
{ timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' },
|
||||
{ timestamp: Date.parse('2026-08-08T12:00:02Z'), level: 'error', message: 'Matrix request failed' },
|
||||
]);
|
||||
}),
|
||||
http.get('/api/chats/sync', () =>
|
||||
HttpResponse.json({
|
||||
syncing: true,
|
||||
syncingKind: 'delta',
|
||||
lastFullSyncAt: null,
|
||||
lastDeltaSyncAt: null,
|
||||
hasEverFullSynced: false,
|
||||
cachedChats: 18,
|
||||
progress: {
|
||||
kind: 'delta',
|
||||
phase: 'loading-details',
|
||||
current: 4,
|
||||
total: 10,
|
||||
label: 'Loading room history',
|
||||
startedAt: Date.parse('2026-08-08T11:59:00Z'),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPage(<OperationsPage realtimeEnabled={false} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
|
||||
const activityRegion = screen.getByRole('region', { name: 'Activity' });
|
||||
expect(await within(activityRegion).findByText('Reply from u/alex')).toBeInTheDocument();
|
||||
const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
|
||||
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status', { name: 'Realtime connection' })).toHaveTextContent('State may be stale');
|
||||
|
||||
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
|
||||
expect(consoleRegion).toHaveTextContent('Sync complete');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed');
|
||||
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
|
||||
expect(consoleRegion).not.toHaveTextContent('Sync complete');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed');
|
||||
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
|
||||
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
||||
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createDashboardRealtimeClient,
|
||||
queryKeysForDashboardEvent,
|
||||
type DashboardEventSource,
|
||||
} from '../../apps/dashboard/src/features/operations/realtime-client.js';
|
||||
|
||||
class FakeEventSource implements DashboardEventSource {
|
||||
readonly listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
readonly close = vi.fn();
|
||||
|
||||
addEventListener(type: string, listener: (event: unknown) => void): void {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: string, event: unknown = {}): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe('dashboard realtime client', () => {
|
||||
it('tracks connect/reconnect state and parses typed events before delivery', () => {
|
||||
const source = new FakeEventSource();
|
||||
const states: string[] = [];
|
||||
const events = vi.fn();
|
||||
const invalid = vi.fn();
|
||||
const client = createDashboardRealtimeClient({
|
||||
sourceFactory: () => source,
|
||||
onEvent: events,
|
||||
onStateChange: (state) => states.push(`${state.status}:${state.isStale}`),
|
||||
onInvalidEvent: invalid,
|
||||
});
|
||||
|
||||
client.start();
|
||||
source.emit('open');
|
||||
source.emit('log.created', {
|
||||
data: JSON.stringify({
|
||||
id: 'event-1',
|
||||
occurredAt: '2026-08-08T12:00:00.000Z',
|
||||
type: 'log.created',
|
||||
payload: { level: 'info', message: 'Connected' },
|
||||
}),
|
||||
});
|
||||
source.emit('log.created', { data: '{bad json' });
|
||||
source.emit('error');
|
||||
|
||||
expect(states).toEqual(['connecting:true', 'connected:false', 'reconnecting:true']);
|
||||
expect(events).toHaveBeenCalledOnce();
|
||||
expect(events).toHaveBeenCalledWith(expect.objectContaining({ id: 'event-1', type: 'log.created' }));
|
||||
expect(invalid).toHaveBeenCalledOnce();
|
||||
expect(client.getSnapshot()).toMatchObject({ status: 'reconnecting', isStale: true, lastEventId: 'event-1' });
|
||||
|
||||
client.stop();
|
||||
expect(source.close).toHaveBeenCalledOnce();
|
||||
expect(client.getSnapshot()).toMatchObject({ status: 'closed', isStale: true });
|
||||
});
|
||||
|
||||
it('maps events to the queries whose authoritative snapshots need refresh', () => {
|
||||
expect(
|
||||
queryKeysForDashboardEvent({
|
||||
id: '1',
|
||||
occurredAt: '2026-08-08T12:00:00.000Z',
|
||||
type: 'sync.progress',
|
||||
payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 },
|
||||
}),
|
||||
).toEqual([
|
||||
['operations', 'sync-status'],
|
||||
['conversations'],
|
||||
['media'],
|
||||
]);
|
||||
expect(
|
||||
queryKeysForDashboardEvent({
|
||||
id: '2',
|
||||
occurredAt: '2026-08-08T12:00:00.000Z',
|
||||
type: 'rate-limit.changed',
|
||||
payload: { limited: true },
|
||||
}),
|
||||
).toEqual([
|
||||
['dashboard', 'stats'],
|
||||
['dashboard', 'timers'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js';
|
||||
import { LogBroadcaster } from '../src/web/log-broadcaster.js';
|
||||
|
||||
describe('LogBroadcaster realtime bridge', () => {
|
||||
it('publishes each log through the typed event hub', () => {
|
||||
const occurredAt = new Date('2026-08-08T15:00:00.000Z');
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
clock: { now: () => occurredAt },
|
||||
});
|
||||
const received: RealtimeEvent[] = [];
|
||||
let recent: unknown;
|
||||
hub.subscribe((event) => received.push(event));
|
||||
const broadcaster = new LogBroadcaster(hub);
|
||||
|
||||
broadcaster.push('warn', 'Connection is stale', { attempts: 2 });
|
||||
broadcaster.recent(
|
||||
{ query: {} } as never,
|
||||
{ json: (value: unknown) => { recent = value; } } as never,
|
||||
);
|
||||
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'log.created',
|
||||
payload: {
|
||||
level: 'warn',
|
||||
message: 'Connection is stale',
|
||||
data: { attempts: 2 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(recent).toEqual([
|
||||
expect.objectContaining({ timestamp: occurredAt.getTime() }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('VNC container entrypoint', () => {
|
||||
it('starts x11vnc with supported arguments and verifies its port before noVNC', () => {
|
||||
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
|
||||
|
||||
expect(script).not.toContain('-nopw_warn');
|
||||
expect(script).toContain('kill -0 "${X11VNC_PID}"');
|
||||
expect(script).toContain('/dev/tcp/127.0.0.1/${VNC_PORT}');
|
||||
expect(script.indexOf('VNC server is ready')).toBeLessThan(
|
||||
script.indexOf('starting noVNC'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
RealtimeEventHub,
|
||||
} from '../../../src/web/events/index.js';
|
||||
import {
|
||||
createExpressSseHandler,
|
||||
type RecurringTask,
|
||||
type RecurringTaskScheduler,
|
||||
} from '../../../src/web/events/express-sse-adapter.js';
|
||||
|
||||
class FakeRequest extends EventEmitter {
|
||||
constructor(private readonly lastEventId?: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
get(name: string): string | undefined {
|
||||
return name.toLowerCase() === 'last-event-id'
|
||||
? this.lastEventId
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeResponse extends EventEmitter {
|
||||
readonly headers = new Map<string, string>();
|
||||
readonly frames: string[] = [];
|
||||
readonly flushHeaders = vi.fn();
|
||||
readonly end = vi.fn(() => {
|
||||
this.writableEnded = true;
|
||||
return this;
|
||||
});
|
||||
destroyed = false;
|
||||
writableEnded = false;
|
||||
writeResults: boolean[] = [];
|
||||
throwOnWriteCall: number | undefined;
|
||||
private writeCalls = 0;
|
||||
|
||||
setHeader(name: string, value: string): this {
|
||||
this.headers.set(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
write(frame: string): boolean {
|
||||
this.writeCalls += 1;
|
||||
if (this.writeCalls === this.throwOnWriteCall) {
|
||||
throw new Error('socket write failed');
|
||||
}
|
||||
this.frames.push(frame);
|
||||
return this.writeResults.shift() ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRecurringTask implements RecurringTask {
|
||||
readonly cancel = vi.fn();
|
||||
}
|
||||
|
||||
class FakeScheduler implements RecurringTaskScheduler {
|
||||
readonly scheduled: Array<{
|
||||
intervalMs: number;
|
||||
callback: () => void;
|
||||
task: FakeRecurringTask;
|
||||
}> = [];
|
||||
|
||||
every(intervalMs: number, callback: () => void): RecurringTask {
|
||||
const task = new FakeRecurringTask();
|
||||
this.scheduled.push({ intervalMs, callback, task });
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
function asRequest(request: FakeRequest): Request {
|
||||
return request as unknown as Request;
|
||||
}
|
||||
|
||||
function asResponse(response: FakeResponse): Response {
|
||||
return response as unknown as Response;
|
||||
}
|
||||
|
||||
function createHub(logger?: { warn: ReturnType<typeof vi.fn> }) {
|
||||
let sequence = 0;
|
||||
return new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
clock: { now: () => new Date('2026-08-08T12:00:00.000Z') },
|
||||
idGenerator: { next: () => `event-${++sequence}` },
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
describe('createExpressSseHandler', () => {
|
||||
it('sets streaming headers and resumes ordered event frames after Last-Event-ID', () => {
|
||||
const hub = createHub();
|
||||
hub.publish('log.created', { level: 'info', message: 'already seen' });
|
||||
const second = hub.publish('bot.status.changed', {
|
||||
status: 'running',
|
||||
dryRun: false,
|
||||
});
|
||||
const third = hub.publish('sync.started', {
|
||||
syncId: 'sync-1',
|
||||
kind: 'media',
|
||||
});
|
||||
const scheduler = new FakeScheduler();
|
||||
const handler = createExpressSseHandler({ hub, scheduler });
|
||||
const request = new FakeRequest('event-1');
|
||||
const response = new FakeResponse();
|
||||
|
||||
handler(asRequest(request), asResponse(response), vi.fn());
|
||||
|
||||
expect(response.headers).toEqual(
|
||||
new Map([
|
||||
['Content-Type', 'text/event-stream; charset=utf-8'],
|
||||
['Cache-Control', 'no-cache, no-transform'],
|
||||
['Connection', 'keep-alive'],
|
||||
['X-Accel-Buffering', 'no'],
|
||||
]),
|
||||
);
|
||||
expect(response.flushHeaders).toHaveBeenCalledOnce();
|
||||
expect(response.frames).toEqual([
|
||||
': connected\n\n',
|
||||
`id: event-2\nevent: bot.status.changed\ndata: ${JSON.stringify(second)}\n\n`,
|
||||
`id: event-3\nevent: sync.started\ndata: ${JSON.stringify(third)}\n\n`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('replays the full available window when Last-Event-ID is absent', () => {
|
||||
const hub = createHub();
|
||||
hub.publish('log.created', { level: 'info', message: 'one' });
|
||||
hub.publish('log.created', { level: 'warn', message: 'two' });
|
||||
const response = new FakeResponse();
|
||||
|
||||
createExpressSseHandler({ hub, scheduler: new FakeScheduler() })(
|
||||
asRequest(new FakeRequest()),
|
||||
asResponse(response),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
|
||||
': connected',
|
||||
'id: event-1',
|
||||
'id: event-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits timestamped heartbeats through injected scheduling and clock dependencies', () => {
|
||||
const hub = createHub();
|
||||
const scheduler = new FakeScheduler();
|
||||
const response = new FakeResponse();
|
||||
const handler = createExpressSseHandler({
|
||||
hub,
|
||||
scheduler,
|
||||
clock: { now: () => new Date('2026-08-08T12:34:56.789Z') },
|
||||
heartbeatIntervalMs: 5_000,
|
||||
});
|
||||
handler(asRequest(new FakeRequest()), asResponse(response), vi.fn());
|
||||
|
||||
expect(scheduler.scheduled).toHaveLength(1);
|
||||
expect(scheduler.scheduled[0]?.intervalMs).toBe(5_000);
|
||||
scheduler.scheduled[0]?.callback();
|
||||
|
||||
expect(response.frames.at(-1)).toBe(
|
||||
': heartbeat 2026-08-08T12:34:56.789Z\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('unsubscribes and cancels its heartbeat when the request closes', () => {
|
||||
const hub = createHub();
|
||||
const scheduler = new FakeScheduler();
|
||||
const response = new FakeResponse();
|
||||
const request = new FakeRequest();
|
||||
createExpressSseHandler({ hub, scheduler })(
|
||||
asRequest(request),
|
||||
asResponse(response),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
request.emit('close');
|
||||
request.emit('close');
|
||||
hub.publish('log.created', { level: 'info', message: 'after close' });
|
||||
|
||||
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
|
||||
expect(response.frames).toEqual([': connected\n\n']);
|
||||
});
|
||||
|
||||
it('queues live events during backpressure and flushes them in order on drain', () => {
|
||||
const hub = createHub();
|
||||
const response = new FakeResponse();
|
||||
response.writeResults = [true, false, true];
|
||||
createExpressSseHandler({ hub, scheduler: new FakeScheduler() })(
|
||||
asRequest(new FakeRequest()),
|
||||
asResponse(response),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
hub.publish('log.created', { level: 'info', message: 'fills buffer' });
|
||||
hub.publish('log.created', { level: 'info', message: 'queued' });
|
||||
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
|
||||
': connected',
|
||||
'id: event-1',
|
||||
]);
|
||||
|
||||
response.emit('drain');
|
||||
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
|
||||
': connected',
|
||||
'id: event-1',
|
||||
'id: event-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes a persistently backpressured response when its bounded queue fills', () => {
|
||||
const hub = createHub();
|
||||
const scheduler = new FakeScheduler();
|
||||
const response = new FakeResponse();
|
||||
response.writeResults = [true, false];
|
||||
createExpressSseHandler({
|
||||
hub,
|
||||
scheduler,
|
||||
maxPendingFrames: 1,
|
||||
})(asRequest(new FakeRequest()), asResponse(response), vi.fn());
|
||||
|
||||
hub.publish('log.created', { level: 'info', message: 'buffered by Node' });
|
||||
hub.publish('log.created', { level: 'info', message: 'adapter queue' });
|
||||
hub.publish('log.created', { level: 'info', message: 'overflow' });
|
||||
hub.publish('log.created', { level: 'info', message: 'not delivered' });
|
||||
|
||||
expect(response.end).toHaveBeenCalledOnce();
|
||||
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
|
||||
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
|
||||
': connected',
|
||||
'id: event-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles closed and throwing responses without leaking subscriber errors', () => {
|
||||
const logger = { warn: vi.fn() };
|
||||
const closedHub = createHub(logger);
|
||||
const closedResponse = new FakeResponse();
|
||||
closedResponse.destroyed = true;
|
||||
createExpressSseHandler({
|
||||
hub: closedHub,
|
||||
scheduler: new FakeScheduler(),
|
||||
})(asRequest(new FakeRequest()), asResponse(closedResponse), vi.fn());
|
||||
closedHub.publish('log.created', { level: 'info', message: 'ignored' });
|
||||
|
||||
const throwingHub = createHub(logger);
|
||||
const scheduler = new FakeScheduler();
|
||||
const throwingResponse = new FakeResponse();
|
||||
throwingResponse.throwOnWriteCall = 2;
|
||||
createExpressSseHandler({ hub: throwingHub, scheduler })(
|
||||
asRequest(new FakeRequest()),
|
||||
asResponse(throwingResponse),
|
||||
vi.fn(),
|
||||
);
|
||||
throwingHub.publish('log.created', { level: 'error', message: 'throws' });
|
||||
throwingHub.publish('log.created', { level: 'info', message: 'ignored' });
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
|
||||
expect(throwingResponse.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('releases the connection if the event hub cannot accept a subscriber', () => {
|
||||
const hub = createHub();
|
||||
hub.close();
|
||||
const scheduler = new FakeScheduler();
|
||||
const response = new FakeResponse();
|
||||
const handler = createExpressSseHandler({ hub, scheduler });
|
||||
|
||||
expect(() =>
|
||||
handler(
|
||||
asRequest(new FakeRequest()),
|
||||
asResponse(response),
|
||||
vi.fn(),
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(response.end).toHaveBeenCalledOnce();
|
||||
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('validates adapter limits', () => {
|
||||
const hub = createHub();
|
||||
expect(() =>
|
||||
createExpressSseHandler({
|
||||
hub,
|
||||
scheduler: new FakeScheduler(),
|
||||
heartbeatIntervalMs: 0,
|
||||
}),
|
||||
).toThrow('heartbeatIntervalMs must be a positive integer');
|
||||
expect(() =>
|
||||
createExpressSseHandler({
|
||||
hub,
|
||||
scheduler: new FakeScheduler(),
|
||||
maxPendingFrames: 0,
|
||||
}),
|
||||
).toThrow('maxPendingFrames must be a positive integer');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
||||
import {
|
||||
MonotonicEventIdGenerator,
|
||||
RealtimeEventHub,
|
||||
type RealtimeEvent,
|
||||
type RealtimeEventMap,
|
||||
} from '../../../src/web/events/index.js';
|
||||
|
||||
function createDependencies() {
|
||||
let timestamp = Date.parse('2026-08-08T12:00:00.000Z');
|
||||
let sequence = 0;
|
||||
|
||||
return {
|
||||
clock: {
|
||||
now: () => new Date(timestamp++),
|
||||
},
|
||||
idGenerator: {
|
||||
next: () => `event-${++sequence}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('RealtimeEventHub', () => {
|
||||
it('creates typed envelopes with stable IDs and timestamps', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
...createDependencies(),
|
||||
});
|
||||
|
||||
const event = hub.publish('bot.status.changed', {
|
||||
status: 'running',
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(event).toStrictEqual({
|
||||
id: 'event-1',
|
||||
occurredAt: '2026-08-08T12:00:00.000Z',
|
||||
type: 'bot.status.changed',
|
||||
payload: { status: 'running', dryRun: false },
|
||||
});
|
||||
expectTypeOf(event).toEqualTypeOf<
|
||||
RealtimeEvent<'bot.status.changed'>
|
||||
>();
|
||||
});
|
||||
|
||||
it('supports every event family through the event map', () => {
|
||||
expectTypeOf<keyof RealtimeEventMap>().toEqualTypeOf<
|
||||
| 'bot.status.changed'
|
||||
| 'message.sent'
|
||||
| 'message.failed'
|
||||
| 'conversation.updated'
|
||||
| 'reply.received'
|
||||
| 'sync.started'
|
||||
| 'sync.progress'
|
||||
| 'sync.completed'
|
||||
| 'sync.failed'
|
||||
| 'rate-limit.changed'
|
||||
| 'reddit.session.changed'
|
||||
| 'log.created'
|
||||
>();
|
||||
});
|
||||
|
||||
it('fans out live events and stops delivery after unsubscribe', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
...createDependencies(),
|
||||
});
|
||||
const listener = vi.fn();
|
||||
const subscription = hub.subscribe(listener);
|
||||
|
||||
hub.publish('message.sent', {
|
||||
messageId: 'message-1',
|
||||
recipient: 'redditor',
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
subscription.unsubscribe();
|
||||
hub.publish('conversation.updated', {
|
||||
conversationId: 'conversation-1',
|
||||
status: 'awaiting-reply',
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0]?.[0]).toMatchObject({
|
||||
id: 'event-1',
|
||||
type: 'message.sent',
|
||||
});
|
||||
expect(subscription.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('retains only the configured replay capacity', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 2,
|
||||
...createDependencies(),
|
||||
});
|
||||
|
||||
hub.publish('log.created', { level: 'info', message: 'one' });
|
||||
hub.publish('log.created', { level: 'info', message: 'two' });
|
||||
hub.publish('log.created', { level: 'info', message: 'three' });
|
||||
|
||||
expect(hub.recent()).toEqual([
|
||||
expect.objectContaining({ id: 'event-2' }),
|
||||
expect.objectContaining({ id: 'event-3' }),
|
||||
]);
|
||||
expect(hub.recent(1)).toEqual([
|
||||
expect.objectContaining({ id: 'event-3' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('resumes strictly after Last-Event-ID and replays the available window for an unknown ID', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 3,
|
||||
...createDependencies(),
|
||||
});
|
||||
hub.publish('log.created', { level: 'info', message: 'one' });
|
||||
hub.publish('log.created', { level: 'info', message: 'two' });
|
||||
hub.publish('log.created', { level: 'info', message: 'three' });
|
||||
|
||||
const resumed = vi.fn();
|
||||
hub.subscribe(resumed, { lastEventId: 'event-1' });
|
||||
expect(resumed.mock.calls.map(([event]) => event.id)).toEqual([
|
||||
'event-2',
|
||||
'event-3',
|
||||
]);
|
||||
|
||||
const boundedHub = new RealtimeEventHub({
|
||||
capacity: 2,
|
||||
...createDependencies(),
|
||||
});
|
||||
boundedHub.publish('log.created', { level: 'info', message: 'evicted' });
|
||||
boundedHub.publish('log.created', { level: 'info', message: 'available one' });
|
||||
boundedHub.publish('log.created', { level: 'info', message: 'available two' });
|
||||
const stale = vi.fn();
|
||||
boundedHub.subscribe(stale, { lastEventId: 'event-1' });
|
||||
expect(stale.mock.calls.map(([event]) => event.id)).toEqual([
|
||||
'event-2',
|
||||
'event-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves order and prevents duplicate delivery when publish occurs during replay', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
...createDependencies(),
|
||||
});
|
||||
hub.publish('log.created', { level: 'info', message: 'one' });
|
||||
hub.publish('log.created', { level: 'info', message: 'two' });
|
||||
const received: string[] = [];
|
||||
|
||||
hub.subscribe((event) => {
|
||||
received.push(event.id);
|
||||
if (event.id === 'event-1') {
|
||||
hub.publish('log.created', { level: 'info', message: 'during replay' });
|
||||
}
|
||||
});
|
||||
|
||||
expect(received).toEqual(['event-1', 'event-2', 'event-3']);
|
||||
});
|
||||
|
||||
it('keeps every subscriber in order across re-entrant publishes', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
...createDependencies(),
|
||||
});
|
||||
const firstReceived: string[] = [];
|
||||
const secondReceived: string[] = [];
|
||||
hub.subscribe((event) => {
|
||||
firstReceived.push(event.id);
|
||||
if (event.id === 'event-1') {
|
||||
hub.publish('log.created', { level: 'info', message: 'nested' });
|
||||
}
|
||||
});
|
||||
hub.subscribe((event) => secondReceived.push(event.id));
|
||||
|
||||
hub.publish('log.created', { level: 'info', message: 'outer' });
|
||||
|
||||
expect(firstReceived).toEqual(['event-1', 'event-2']);
|
||||
expect(secondReceived).toEqual(['event-1', 'event-2']);
|
||||
});
|
||||
|
||||
it('isolates failed subscribers and reports failures through an injected logger', () => {
|
||||
const logger = { warn: vi.fn() };
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
logger,
|
||||
...createDependencies(),
|
||||
});
|
||||
const healthyListener = vi.fn();
|
||||
const failedSubscription = hub.subscribe(() => {
|
||||
throw new Error('client disconnected');
|
||||
});
|
||||
hub.subscribe(healthyListener);
|
||||
|
||||
hub.publish('reddit.session.changed', { status: 'authenticated' });
|
||||
hub.publish('reddit.session.changed', { status: 'expired' });
|
||||
|
||||
expect(failedSubscription.closed).toBe(true);
|
||||
expect(healthyListener).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledOnce();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Realtime event subscriber failed; subscription closed',
|
||||
expect.objectContaining({ eventId: 'event-1', error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes all subscriptions cleanly and rejects use after close', () => {
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 10,
|
||||
...createDependencies(),
|
||||
});
|
||||
const first = hub.subscribe(vi.fn());
|
||||
const second = hub.subscribe(vi.fn());
|
||||
|
||||
hub.close();
|
||||
hub.close();
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
expect(second.closed).toBe(true);
|
||||
expect(hub.closed).toBe(true);
|
||||
expect(() =>
|
||||
hub.publish('sync.started', { syncId: 'sync-1', kind: 'media' }),
|
||||
).toThrow('RealtimeEventHub is closed');
|
||||
expect(() => hub.subscribe(vi.fn())).toThrow(
|
||||
'RealtimeEventHub is closed',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates construction and recent limits', () => {
|
||||
expect(
|
||||
() =>
|
||||
new RealtimeEventHub({
|
||||
capacity: 0,
|
||||
...createDependencies(),
|
||||
}),
|
||||
).toThrow('capacity must be a positive integer');
|
||||
|
||||
const hub = new RealtimeEventHub({
|
||||
capacity: 1,
|
||||
...createDependencies(),
|
||||
});
|
||||
expect(() => hub.recent(-1)).toThrow(
|
||||
'limit must be a non-negative integer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MonotonicEventIdGenerator', () => {
|
||||
it('generates unique sortable IDs when timestamps repeat or move backwards', () => {
|
||||
const generator = new MonotonicEventIdGenerator();
|
||||
const first = generator.next(new Date('2026-08-08T12:00:00.001Z'));
|
||||
const second = generator.next(new Date('2026-08-08T12:00:00.001Z'));
|
||||
const third = generator.next(new Date('2026-08-08T12:00:00.000Z'));
|
||||
|
||||
expect(new Set([first, second, third])).toHaveLength(3);
|
||||
expect([first, second, third]).toEqual([...[first, second, third]].sort());
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
include: ['tests/**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
|
||||
Reference in new issue
Block a user