Shared foundation: - New Modal component on native <dialog>.showModal() (focus trap, inert background, Escape, focus restore, scroll lock); all eight dialogs and both globalThis.confirm calls migrated to it - Design tokens for success/warning/danger surfaces; pill danger variant; .sr-only utility; toast layering above dialog backdrops; not-allowed cursor for validity-disabled buttons Conversations/attention: auto-scroll with jump-to-latest, per-conversation draft persistence, suggestion undo, 30s auto-refresh with refresh controls, unread emphasis and counts, blur-consistent share dialog, empty-accounts guidance, collapsed user lookup, relative timestamps, danger pills for critical items Media/library/outreach: click-blurred-to-reveal, lightbox with arrow navigation and captions, dirty-form preservation across refetches, upload validation with thumbnail preview, save feedback, result counts with load-more and clear-filters, ticking countdowns from a single clock Overview/performance/operations/settings: mutation errors inside the confirm dialog, live sending-window countdown, offline-aware loading states, per-panel loading/error with retry, 7/30/90-day range toggle, console scroll yielding with shown/retained counts, risky-save matching by subreddit name instead of index, useBlocker unsaved-changes guard, single-pass validation with focus-to-first-error, access panel JSON validation and input reset Tests: 806 -> 844 passing; both typechecks clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
298 lines
15 KiB
TypeScript
298 lines
15 KiB
TypeScript
import { useEffect, useReducer, useRef, useState } from 'react';
|
|
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
|
|
import { useDashboardRealtime } from '../../app/realtime-provider.js';
|
|
import { Modal } from '../../components/modal.js';
|
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
|
import { RedditUserText } from '../../components/reddit-user-text.js';
|
|
import { OriginPostButton } from '../../components/origin-post.js';
|
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
|
import {
|
|
createLiveConsoleState,
|
|
reduceLiveConsoleState,
|
|
selectVisibleConsoleEntries,
|
|
type ConsoleEntry,
|
|
type ConsoleLevelFilter,
|
|
} from './live-console-model.js';
|
|
import { useActivityQuery, useFetchAllChatsMutation, useLogsQuery, useSyncStatusQuery, useTriggerChatSyncMutation } from './operations-queries.js';
|
|
import type { RealtimeConnectionSnapshot } from './realtime-client.js';
|
|
import './operations.css';
|
|
|
|
function formatTimestamp(timestamp: number | string): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'medium',
|
|
}).format(typeof timestamp === 'number' ? timestamp : new Date(timestamp));
|
|
}
|
|
|
|
function ActivityView({ items }: { items: readonly ActivityItem[] }) {
|
|
return items.length === 0 ? (
|
|
<p className="feature-empty">No recent operator activity.</p>
|
|
) : (
|
|
<ol className="operations-activity-list">
|
|
{items.map((item, index) => (
|
|
<li key={`${item.timestamp}-${index}`}>
|
|
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
|
<AccountOwnerBadge owner={item.owner} />
|
|
<div><strong>{item.postUrl && item.postTitle ? (
|
|
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
|
{item.message}
|
|
</OriginPostButton>
|
|
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient && !item.message.includes(`u/${item.recipient}`) ? <> · <RedditUsernameLink username={item.recipient} /></> : null}</small></div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
function SyncSummary({ status }: { status: ChatSyncStatus }) {
|
|
const progress = status.progress;
|
|
return (
|
|
<div className="sync-summary">
|
|
<div><span className={`pill ${status.syncing ? 'warning' : 'success'}`}>{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}</span><strong>{status.cachedChats} cached conversations</strong></div>
|
|
{progress ? (
|
|
<div>
|
|
<div className="sync-progress-label"><span>{progress.label ?? progress.phase}</span><strong>{progress.current} of {progress.total}</strong></div>
|
|
<progress aria-label="Sync progress" max={Math.max(1, progress.total)} value={progress.current} />
|
|
</div>
|
|
) : null}
|
|
<dl className="sync-history">
|
|
<div><dt>Last delta sync</dt><dd>{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}</dd></div>
|
|
<div><dt>Last full sync</dt><dd>{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}</dd></div>
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
|
|
switch (snapshot.status) {
|
|
case 'connected': return 'Realtime connected';
|
|
case 'connecting': return 'Connecting to realtime updates';
|
|
case 'reconnecting': return 'Realtime reconnecting';
|
|
case 'closed': return 'Realtime closed';
|
|
case 'idle': return 'Realtime unavailable';
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const NOTICE_DISMISS_MS = 5_000;
|
|
|
|
const expensiveSyncCopy = {
|
|
full: {
|
|
title: 'Run a full cache sync?',
|
|
body: 'This rebuilds the entire chat cache and can take significantly longer than a delta sync.',
|
|
confirmLabel: 'Run full sync',
|
|
},
|
|
'fetch-all': {
|
|
title: 'Fetch every chat now?',
|
|
body: 'This is a legacy blocking operation that fetches every chat and may take a while.',
|
|
confirmLabel: 'Start fetching',
|
|
},
|
|
} as const;
|
|
|
|
function ConfirmSyncDialog({ kind, onCancel, onConfirm }: {
|
|
kind: 'full' | 'fetch-all';
|
|
onCancel(): void;
|
|
onConfirm(): void;
|
|
}) {
|
|
const copy = expensiveSyncCopy[kind];
|
|
return (
|
|
<Modal className="dialog-card" labelledBy="confirm-sync-title" onClose={onCancel}>
|
|
<span className="eyebrow">Confirm action</span>
|
|
<h2 id="confirm-sync-title">{copy.title}</h2>
|
|
<p>{copy.body}</p>
|
|
<div className="dialog-actions">
|
|
<button autoFocus className="button secondary" onClick={onCancel} type="button">Cancel</button>
|
|
<button className="button primary" onClick={onConfirm} type="button">{copy.confirmLabel}</button>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
|
|
const realtime = useDashboardRealtime();
|
|
const activityQuery = useActivityQuery();
|
|
const logsQuery = useLogsQuery();
|
|
const syncQuery = useSyncStatusQuery();
|
|
const syncMutation = useTriggerChatSyncMutation();
|
|
const fetchAllMutation = useFetchAllChatsMutation();
|
|
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
|
|
const [pendingSyncConfirm, setPendingSyncConfirm] = useState<'full' | 'fetch-all' | null>(null);
|
|
const [pauseAnnouncement, setPauseAnnouncement] = useState('');
|
|
const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);
|
|
const [consoleState, dispatchConsole] = useReducer(
|
|
reduceLiveConsoleState,
|
|
createLiveConsoleState(500),
|
|
);
|
|
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
|
|
const consoleEnd = useRef<HTMLDivElement>(null);
|
|
const consoleViewport = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
logsQuery.data?.forEach((entry, index) => {
|
|
dispatchConsole({
|
|
type: 'append',
|
|
entry: {
|
|
id: `initial-${entry.timestamp}-${index}`,
|
|
occurredAt: new Date(entry.timestamp).toISOString(),
|
|
level: entry.level,
|
|
message: entry.message,
|
|
data: entry.data,
|
|
},
|
|
});
|
|
});
|
|
}, [logsQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (!realtimeEnabled) return;
|
|
function onEvent(event: DashboardEvent): void {
|
|
if (event.type === 'log.created') {
|
|
const entry: ConsoleEntry = {
|
|
id: event.id,
|
|
occurredAt: event.occurredAt,
|
|
level: event.payload.level,
|
|
message: event.payload.message,
|
|
data: event.payload.data,
|
|
};
|
|
dispatchConsole({ type: 'append', entry });
|
|
}
|
|
}
|
|
return realtime.subscribe(onEvent);
|
|
}, [realtime, realtimeEnabled]);
|
|
|
|
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
|
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
|
|
|
|
function runExpensiveSync(kind: 'full' | 'fetch-all') {
|
|
setPendingSyncConfirm(null);
|
|
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
|
|
}
|
|
|
|
const resetSyncMutation = syncMutation.reset;
|
|
const resetFetchAllMutation = fetchAllMutation.reset;
|
|
|
|
useEffect(() => {
|
|
if (!syncMutation.isSuccess) return;
|
|
const timer = globalThis.setTimeout(() => resetSyncMutation(), NOTICE_DISMISS_MS);
|
|
return () => globalThis.clearTimeout(timer);
|
|
}, [resetSyncMutation, syncMutation.isSuccess]);
|
|
|
|
useEffect(() => {
|
|
if (!fetchAllMutation.isSuccess) return;
|
|
const timer = globalThis.setTimeout(() => resetFetchAllMutation(), NOTICE_DISMISS_MS);
|
|
return () => globalThis.clearTimeout(timer);
|
|
}, [fetchAllMutation.isSuccess, resetFetchAllMutation]);
|
|
|
|
useEffect(() => {
|
|
if (!consoleClearNotice) return;
|
|
const timer = globalThis.setTimeout(() => setConsoleClearNotice(false), NOTICE_DISMISS_MS);
|
|
return () => globalThis.clearTimeout(timer);
|
|
}, [consoleClearNotice]);
|
|
|
|
function handleConsoleScroll() {
|
|
const viewport = consoleViewport.current;
|
|
if (!viewport) return;
|
|
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 16;
|
|
setIsPinnedToBottom(atBottom);
|
|
}
|
|
|
|
function jumpToLatest() {
|
|
setIsPinnedToBottom(true);
|
|
consoleEnd.current?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (
|
|
consoleState.isAutoScrollEnabled &&
|
|
isPinnedToBottom &&
|
|
!consoleState.isPaused &&
|
|
typeof consoleEnd.current?.scrollIntoView === 'function'
|
|
) {
|
|
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused, isPinnedToBottom]);
|
|
|
|
return (
|
|
<div className="page operations-page">
|
|
<header className="page-header"><div><span className="eyebrow">Observability</span><h1>Operations</h1><p>Human-readable activity, background work, and raw diagnostic output in distinct views.</p></div></header>
|
|
<div aria-label="Realtime connection" className={`connection-banner ${connection.isStale ? 'stale' : ''}`} role="status">
|
|
<strong>{connectionLabel(connection)}</strong>
|
|
{connection.isStale ? <span>State may be stale</span> : <span>Events are updating live</span>}
|
|
</div>
|
|
<div className="operations-summary-grid">
|
|
<section aria-labelledby="operations-activity-heading" className="panel feature-panel" role="region">
|
|
<div className="panel-heading"><div><span className="eyebrow">Readable history</span><h2 id="operations-activity-heading">Activity</h2></div></div>
|
|
{activityQuery.isPending ? <p role="status">Loading activity…</p> : activityQuery.isError ? <p role="alert">{activityQuery.error.message}</p> : <ActivityView items={activityQuery.data} />}
|
|
</section>
|
|
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
|
|
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
|
|
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('fetch-all')} type="button">Fetch all chats</button></div></>}
|
|
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
|
|
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
|
|
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
|
|
{fetchAllMutation.isError ? <p role="alert">{fetchAllMutation.error.message}</p> : null}
|
|
</section>
|
|
</div>
|
|
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
|
|
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{visibleEntries.length === consoleState.entries.length
|
|
? `${consoleState.entries.length} retained`
|
|
: `${visibleEntries.length} shown · ${consoleState.entries.length} retained`}</span></div>
|
|
<div className="console-controls">
|
|
<button
|
|
aria-pressed={consoleState.isPaused}
|
|
className="button secondary"
|
|
onClick={() => {
|
|
const paused = !consoleState.isPaused;
|
|
dispatchConsole({ type: 'set-paused', paused });
|
|
setPauseAnnouncement(paused ? 'Console paused.' : 'Console resumed.');
|
|
}}
|
|
type="button"
|
|
>{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
|
|
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
|
|
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
|
|
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
|
|
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
|
|
</div>
|
|
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
|
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
|
|
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
|
|
{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>
|
|
{!isPinnedToBottom ? (
|
|
<button className="button secondary console-jump" onClick={jumpToLatest} type="button">Jump to latest</button>
|
|
) : null}
|
|
</section>
|
|
{pendingSyncConfirm ? (
|
|
<ConfirmSyncDialog
|
|
kind={pendingSyncConfirm}
|
|
onCancel={() => setPendingSyncConfirm(null)}
|
|
onConfirm={() => runExpensiveSync(pendingSyncConfirm)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|