feat(dashboard): add React foundation and overview
This commit is contained in:
1 parent
59c3a61a88
commit
51e3e28a9b
20 files changed
+3347
-244
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user