feat(dashboard): add performance and operations slices
This commit is contained in:
1 parent
69d560d5c5
commit
c7dec45abe
13 files changed
+1059
No files matched your search
@@ -0,0 +1,41 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user