Show per-account delivery status

This commit is contained in:
jjbrunton committed 2026-08-09 16:12:05 +01:00
1 parent 9b86cee9be
commit 22eb32e5bf
14 files changed
+402 -13

No files matched your search

@@ -2,6 +2,7 @@ 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 type { AccountStatus } from '../../../../../packages/api-contracts/src/index.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
@@ -55,6 +56,63 @@ function TimersPanel({ timers }: { timers: Timers }) {
);
}
const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; tone: 'success' | 'warning' | 'neutral'; detail: string }> = {
ready: { label: 'Ready', tone: 'success', detail: 'Available for the next outbound candidate.' },
'bot-stopped': { label: 'Bot stopped', tone: 'neutral', detail: 'Global sending is stopped.' },
'runtime-stopped': { label: 'Runtime stopped', tone: 'warning', detail: 'This account runtime is not running.' },
'runtime-failed': { label: 'Runtime failed', tone: 'warning', detail: 'This account runtime needs attention.' },
'login-required': { label: 'Login required', tone: 'warning', detail: 'Refresh this account session before it can send.' },
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
};
function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {
return (
<section aria-labelledby="account-status-heading" className="panel account-status-panel">
<div className="panel-heading">
<div><span className="eyebrow">Multi-account delivery</span><h2 id="account-status-heading">Account status</h2></div>
<span className="pill neutral">{accounts.length} configured</span>
</div>
{accounts.length === 0 ? <div className="empty-state"><strong>No accounts configured.</strong><span>Add an account under Settings → Access.</span></div> : (
<div className="account-status-grid">
{accounts.map((account) => {
const state = sendingStateCopy[account.sendingState];
return (
<article aria-label={`Status for u/${account.redditUsername}`} className="account-status-card" key={account.accountId}>
<div className="account-status-heading">
<AccountOwnerBadge owner={account} />
<span className={`pill ${state.tone}`}>{state.label}</span>
</div>
<p>{state.detail}</p>
<dl className="account-status-details">
<div><dt>Runtime</dt><dd>{account.runtimeState}</dd></div>
<div><dt>Authentication</dt><dd>{account.authenticated ? 'Authenticated' : 'Unavailable'}</dd></div>
<div><dt>Hourly usage</dt><dd>{account.messagesThisHour} of {account.maxMessagesPerHour}</dd></div>
<div><dt>Remaining</dt><dd>{account.messagesRemaining}</dd></div>
<div><dt>Allowance resets</dt><dd>{formatTime(account.hourlyResetAt)}</dd></div>
<div><dt>Pending replies</dt><dd>{account.actionableReplies}</dd></div>
</dl>
{account.nextMessageAvailableAt && account.nextMessageAvailableAt > Date.now() && !account.invitationCooldown
? <p className="account-status-note">Next send: {formatTime(account.nextMessageAvailableAt)}</p>
: null}
{account.invitationCooldown ? (
<div className="account-status-warning">
<strong>Cooldown until {formatTime(account.invitationCooldown.until)}</strong>
<span>{account.invitationCooldown.reason}</span>
{account.invitationCooldown.redditCode ? <code>{account.invitationCooldown.redditCode}</code> : null}
</div>
) : null}
{account.failure ? <div className="account-status-warning is-error" role="alert"><strong>Runtime failure</strong><span>{account.failure}</span></div> : null}
</article>
);
})}
</div>
)}
</section>
);
}
function ActivityPanel({ stats }: { stats: BotStats }) {
return (
<section aria-labelledby="activity-heading" className="panel activity-panel">
@@ -166,7 +224,9 @@ export function OverviewPage() {
{stats.invitationCooldown ? (
<div className="cooldown-banner" role="status">
<strong>Reddit invitation cooldown</strong>
<strong>{stats.invitationCooldown.redditUsername
? `u/${stats.invitationCooldown.redditUsername} invitation cooldown`
: 'Reddit invitation cooldown'}</strong>
<span>{stats.invitationCooldown.reason}</span>
</div>
) : null}
@@ -178,6 +238,8 @@ export function OverviewPage() {
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
</section>
<AccountStatusPanel accounts={stats.accounts} />
<div className="overview-grid">
{timersQuery.isPending ? (
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>