Show per-account delivery status
This commit is contained in:
1 parent
9b86cee9be
commit
22eb32e5bf
14 files changed
+402
-13
No files matched your search
@@ -8,7 +8,11 @@ export const dashboardQueryKeys = {
|
||||
};
|
||||
|
||||
export function useStatsQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.stats,
|
||||
queryFn: dashboardApi.getStats,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTimersQuery() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { useDashboardRealtime } from '../../app/realtime-provider.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { RedditUserText } from '../../components/reddit-user-text.js';
|
||||
@@ -72,6 +72,16 @@ function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function accountOwnerFromLogData(data: unknown): RedditAccountOwner | undefined {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
|
||||
const accountId = Reflect.get(data, 'accountId');
|
||||
const redditUsername = Reflect.get(data, 'redditUsername');
|
||||
return typeof accountId === 'string' && accountId.length > 0
|
||||
&& typeof redditUsername === 'string' && redditUsername.length > 0
|
||||
? { accountId, redditUsername }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export interface OperationsPageProps {
|
||||
realtimeEnabled?: boolean;
|
||||
}
|
||||
@@ -175,9 +185,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
||||
</div>
|
||||
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
||||
<div aria-live="polite" className="live-console" role="log">
|
||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
|
||||
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span><RedditUserText>{entry.message}</RedditUserText></span></article>
|
||||
))}
|
||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
|
||||
const owner = accountOwnerFromLogData(entry.data);
|
||||
return (
|
||||
<article className={`console-entry ${entry.level}`} key={entry.id}>
|
||||
<time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time>
|
||||
<strong>{entry.level.toUpperCase()}</strong>
|
||||
<span className="console-account">{owner ? <RedditUsernameLink username={owner.redditUsername} /> : 'global'}</span>
|
||||
<span><RedditUserText>{entry.message}</RedditUserText></span>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div ref={consoleEnd} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
|
||||
.console-controls .checkbox-control input { min-height: auto; }
|
||||
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
|
||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(6rem, auto) minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||
.console-entry time { color: #708178; }
|
||||
.console-entry strong { color: #8da49a; }
|
||||
.console-account { color: #708178; }
|
||||
.console-account a { color: var(--accent); font-weight: 700; }
|
||||
.console-entry.warn strong { color: var(--warning); }
|
||||
.console-entry.error strong { color: var(--danger); }
|
||||
.live-console > p { color: var(--muted); text-align: center; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -76,6 +76,20 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.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; }
|
||||
.account-status-panel { margin-bottom: .75rem; }
|
||||
.account-status-grid { display: grid; gap: .75rem; }
|
||||
.account-status-card { display: grid; gap: .75rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.account-status-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.account-status-card > p { margin: 0; color: var(--muted); font-size: .85rem; }
|
||||
.account-status-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem .9rem; margin: 0; }
|
||||
.account-status-details div { display: grid; gap: .1rem; }
|
||||
.account-status-details dt { color: var(--muted); font-size: .72rem; }
|
||||
.account-status-details dd { margin: 0; font-weight: 700; text-transform: capitalize; }
|
||||
.account-status-note { color: var(--muted); }
|
||||
.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .55rem; }
|
||||
.account-status-warning span { font-size: .8rem; }
|
||||
.account-status-warning code { width: fit-content; padding: .15rem .3rem; color: inherit; background: rgba(0, 0, 0, .25); border-radius: .25rem; }
|
||||
.account-status-warning.is-error { color: #ffd1ce; background: #461b18; border-color: #80352f; }
|
||||
.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; }
|
||||
@@ -150,5 +164,6 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.page { padding: 3rem 2rem 5rem; }
|
||||
.navigation-backdrop { display: none; }
|
||||
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.account-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
|
||||
}
|
||||
Reference in new issue
Block a user