feat(dashboard): add React foundation and overview
This commit is contained in:
1 parent
316dc3dd3e
commit
28fcb3ea99
20 files changed
+3347
-244
No files matched your search
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
dist-dashboard/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const activityItemSchema = z.object({
|
||||||
|
timestamp: z.number(),
|
||||||
|
type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']),
|
||||||
|
message: z.string(),
|
||||||
|
recipient: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const botStatsSchema = z.object({
|
||||||
|
isRunning: z.boolean(),
|
||||||
|
dryRunMode: z.boolean(),
|
||||||
|
totalProcessed: z.number(),
|
||||||
|
totalMessagesSent: z.number(),
|
||||||
|
totalResponses: z.number(),
|
||||||
|
responseRate: z.number(),
|
||||||
|
messagesThisHour: z.number(),
|
||||||
|
maxMessagesPerHour: z.number(),
|
||||||
|
subreddits: z.array(z.object({ name: z.string(), type: z.enum(['image', 'chat']) })),
|
||||||
|
targetGenders: z.array(z.string()),
|
||||||
|
recentActivity: z.array(activityItemSchema),
|
||||||
|
invitationCooldown: z
|
||||||
|
.object({
|
||||||
|
until: z.number(),
|
||||||
|
reason: z.string(),
|
||||||
|
redditCode: z.string().nullable(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const timersSchema = z.object({
|
||||||
|
nextMessageAvailableAt: z.number().nullable(),
|
||||||
|
hourlyResetAt: z.number(),
|
||||||
|
isHourlyLimitReached: z.boolean(),
|
||||||
|
messagesRemaining: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const controlResponseSchema = z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
running: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BotStats = z.infer<typeof botStatsSchema>;
|
||||||
|
export type Timers = z.infer<typeof timersSchema>;
|
||||||
|
export type ControlResponse = z.infer<typeof controlResponseSchema>;
|
||||||
@@ -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,23 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export function createDashboardRoutes(): RouteObject[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <AppShell />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <OverviewPage /> },
|
||||||
|
{ path: 'conversations', element: <PlaceholderPage title="Conversations" description="Review conversations, drafts, and replies." /> },
|
||||||
|
{ path: 'media', element: <PlaceholderPage title="Media" description="Browse private media and monitor syncs." /> },
|
||||||
|
{ path: 'performance', element: <PlaceholderPage title="Performance" description="Understand response and strategy performance." /> },
|
||||||
|
{ path: 'operations', element: <PlaceholderPage title="Operations" description="Inspect jobs, events, logs, and diagnostics." /> },
|
||||||
|
{ 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,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,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-dashboard',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+2444
-243
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,9 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "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",
|
"start": "tsx src/index.ts",
|
||||||
"login": "tsx scripts/login.ts",
|
"login": "tsx scripts/login.ts",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -29,16 +33,28 @@
|
|||||||
"p-queue": "^9.0.1",
|
"p-queue": "^9.0.1",
|
||||||
"p-retry": "^7.1.1",
|
"p-retry": "^7.1.1",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.0.3",
|
"@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",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.1.7"
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
globals: false,
|
globals: false,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.{ts,tsx}'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ['text', 'json', 'html'],
|
||||||
|
|||||||
Reference in new issue
Block a user