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
|
||||
dist/
|
||||
dist-dashboard/
|
||||
|
||||
# Environment variables
|
||||
.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",
|
||||
"scripts": {
|
||||
"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",
|
||||
"login": "tsx scripts/login.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
@@ -21,6 +24,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 +33,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,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 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