feat: mailbox-centric activity view, reduce log noise from empty polling cycles

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/1a78a249-127f-4a73-a454-3c6bafbf6e58

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-28 18:45:38 +00:00
parent ccd74c34b6
commit dd4532f660
8 changed files with 229 additions and 117 deletions
+197 -111
View File
@@ -4,9 +4,9 @@ import { useState } from 'react';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { processingRunsApi, ProcessingRun, ProcessingLog } from '@/lib/api';
import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api';
import {
FileText,
Inbox,
ChevronLeft,
ChevronRight,
CheckCircle,
@@ -15,20 +15,18 @@ import {
RefreshCw,
ChevronDown,
ChevronUp,
AlertTriangle,
} from 'lucide-react';
const STATUS_STYLES: Record<string, string> = {
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
partial_failure: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
};
function formatDuration(seconds?: number | null): string {
if (seconds == null) return '—';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const totalSecs = Math.floor(seconds);
return `${Math.floor(totalSecs / 60)}m ${totalSecs % 60}s`;
function formatRelative(iso?: string | null): string {
if (!iso) return 'Never';
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatDate(iso: string): string {
@@ -38,7 +36,14 @@ function formatDate(iso: string): string {
});
}
function RunRow({ run }: { run: ProcessingRun }) {
function formatDuration(seconds?: number | null): string {
if (seconds == null) return '—';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const totalSecs = Math.floor(seconds);
return `${Math.floor(totalSecs / 60)}m ${totalSecs % 60}s`;
}
function RunDetailRow({ run }: { run: ProcessingRun }) {
const [expanded, setExpanded] = useState(false);
const [logsPage, setLogsPage] = useState(1);
@@ -48,57 +53,42 @@ function RunRow({ run }: { run: ProcessingRun }) {
enabled: expanded,
});
const statusClass = STATUS_STYLES[run.status] ?? 'bg-gray-100 text-gray-800';
return (
<>
<tr
className="hover:bg-gray-50 cursor-pointer"
className="hover:bg-gray-50 cursor-pointer text-sm"
onClick={() => setExpanded((v) => !v)}
>
<td className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">
{formatDate(run.started_at)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
<div className="font-medium">{run.account_name ?? '—'}</div>
<div className="text-xs text-gray-400">{run.account_email ?? ''}</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{run.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
{run.emails_fetched}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
{run.emails_forwarded}
</td>
<td className="px-4 py-3 text-sm text-red-600 text-right whitespace-nowrap">
{run.emails_failed > 0 ? run.emails_failed : <span className="text-gray-400">0</span>}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-right whitespace-nowrap">
{formatDuration(run.duration_seconds)}
</td>
<td className="px-4 py-3 text-right">
{expanded ? (
<ChevronUp className="h-4 w-4 text-gray-400 inline" />
<td className="px-4 py-2 text-gray-700 whitespace-nowrap">{formatDate(run.started_at)}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">{run.emails_fetched}</td>
<td className="px-4 py-2 text-right text-gray-700">{run.emails_forwarded}</td>
<td className="px-4 py-2 text-right">
{run.emails_failed > 0 ? (
<span className="text-red-600 font-medium">{run.emails_failed}</span>
) : (
<ChevronDown className="h-4 w-4 text-gray-400 inline" />
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-500">{formatDuration(run.duration_seconds)}</td>
<td className="px-4 py-2 text-right">
{expanded ? (
<ChevronUp className="h-3.5 w-3.5 text-gray-400 inline" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-gray-400 inline" />
)}
</td>
</tr>
{expanded && (
<tr>
<td colSpan={8} className="bg-gray-50 px-6 py-4 border-b border-gray-100">
<td colSpan={6} className="bg-gray-50 px-6 py-3 border-b border-gray-100">
{run.error_message && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{run.error_message}
</div>
)}
{logsLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500 py-2">
<RefreshCw className="h-4 w-4 animate-spin" /> Loading logs
<div className="flex items-center gap-2 text-sm text-gray-500 py-1">
<RefreshCw className="h-4 w-4 animate-spin" /> Loading
</div>
) : logsData && logsData.items.length > 0 ? (
<>
@@ -106,7 +96,6 @@ function RunRow({ run }: { run: ProcessingRun }) {
<thead>
<tr className="text-gray-400 uppercase tracking-wide">
<th className="text-left pb-1 pr-4">Time</th>
<th className="text-left pb-1 pr-4">Level</th>
<th className="text-left pb-1 pr-4">Subject</th>
<th className="text-left pb-1 pr-4">From</th>
<th className="text-left pb-1">Status</th>
@@ -118,19 +107,6 @@ function RunRow({ run }: { run: ProcessingRun }) {
<td className="py-1 pr-4 text-gray-500 whitespace-nowrap">
{formatDate(log.timestamp)}
</td>
<td className="py-1 pr-4">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
log.level === 'ERROR'
? 'bg-red-100 text-red-700'
: log.level === 'WARNING'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{log.level}
</span>
</td>
<td className="py-1 pr-4 text-gray-700 max-w-xs truncate">
{log.email_subject ?? '—'}
</td>
@@ -149,7 +125,7 @@ function RunRow({ run }: { run: ProcessingRun }) {
</tbody>
</table>
{logsData.pages > 1 && (
<div className="flex items-center gap-2 mt-3 text-xs text-gray-500">
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
<button
onClick={(e) => {
e.stopPropagation();
@@ -160,9 +136,7 @@ function RunRow({ run }: { run: ProcessingRun }) {
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span>
Page {logsPage} of {logsData.pages}
</span>
<span>Page {logsPage} of {logsData.pages}</span>
<button
onClick={(e) => {
e.stopPropagation();
@@ -177,7 +151,7 @@ function RunRow({ run }: { run: ProcessingRun }) {
)}
</>
) : (
<p className="text-sm text-gray-400 py-2">No per-email logs recorded for this run.</p>
<p className="text-xs text-gray-400 py-1">No per-email logs for this run.</p>
)}
</td>
</tr>
@@ -186,14 +160,138 @@ function RunRow({ run }: { run: ProcessingRun }) {
);
}
export default function LogsPage() {
const [page, setPage] = useState(1);
function MailboxCard({
account,
runs,
}: {
account: MailAccount;
runs: ProcessingRun[];
}) {
const [showHistory, setShowHistory] = useState(false);
const hasError = !!account.last_error_message;
const lastChecked = account.last_check_at;
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['processing-runs', page],
queryFn: () => processingRunsApi.list({ page, page_size: 20 }),
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Mailbox header */}
<div className="px-5 py-4 flex items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Inbox className="h-5 w-5 text-blue-500 shrink-0" />
<div className="min-w-0">
<p className="font-semibold text-gray-900 truncate">{account.name}</p>
<p className="text-xs text-gray-500 truncate">{account.email_address}</p>
</div>
</div>
{/* Last attempt status */}
<div className="text-right shrink-0">
{hasError ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
<XCircle className="h-4 w-4" />
Error
</span>
) : lastChecked ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
<CheckCircle className="h-4 w-4" />
OK
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
<Clock className="h-4 w-4" />
Pending
</span>
)}
<p className="text-xs text-gray-400 mt-0.5">
Last check: {formatRelative(lastChecked)}
</p>
</div>
</div>
{/* Error message */}
{hasError && (
<div className="mx-5 mb-3 p-2 bg-red-50 border border-red-200 rounded flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700">{account.last_error_message}</p>
</div>
)}
{/* Successful pulls summary */}
<div className="border-t border-gray-100 px-5 py-3">
{runs.length === 0 ? (
<p className="text-xs text-gray-400">No emails fetched yet.</p>
) : (
<>
<button
onClick={() => setShowHistory((v) => !v)}
className="flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{showHistory ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{runs.length} successful pull{runs.length !== 1 ? 's' : ''}
{runs[0] && (
<span className="text-gray-400 font-normal text-xs ml-1">
last {formatRelative(runs[0].started_at)}
</span>
)}
</button>
{showHistory && (
<div className="mt-3 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
<th className="px-4 pb-2">Date</th>
<th className="px-4 pb-2 text-right">Fetched</th>
<th className="px-4 pb-2 text-right">Forwarded</th>
<th className="px-4 pb-2 text-right">Failed</th>
<th className="px-4 pb-2 text-right">Duration</th>
<th className="px-4 pb-2" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{runs.map((run) => (
<RunDetailRow key={run.id} run={run} />
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
);
}
export default function LogsPage() {
const [runsPage, setRunsPage] = useState(1);
const { data: accounts, isLoading: accountsLoading } = useQuery({
queryKey: ['mail-accounts'],
queryFn: mailAccountsApi.list,
});
// Fetch recent successful runs (emails_fetched > 0) across all accounts
const { data: runsData, isLoading: runsLoading, isError, refetch } = useQuery({
queryKey: ['processing-runs-meaningful', runsPage],
queryFn: () =>
processingRunsApi.list({ page: runsPage, page_size: 100, has_emails: true }),
});
const isLoading = accountsLoading || runsLoading;
// Group runs by account id
const runsByAccount = (runsData?.items ?? []).reduce<Record<number, ProcessingRun[]>>(
(acc, run) => {
if (!acc[run.mail_account_id]) acc[run.mail_account_id] = [];
acc[run.mail_account_id].push(run);
return acc;
},
{}
);
return (
<AuthGuard>
<DashboardLayout>
@@ -202,11 +300,11 @@ export default function LogsPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<FileText className="h-6 w-6 text-blue-600" />
Processing Logs
<Inbox className="h-6 w-6 text-blue-600" />
Mailbox Activity
</h1>
<p className="mt-1 text-sm text-gray-500">
History of email processing runs for all your mail accounts.
Last check status and successful email pulls for each mailbox.
</p>
</div>
<button
@@ -218,57 +316,44 @@ export default function LogsPage() {
</button>
</div>
{/* Table */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : isError ? (
<div className="text-center py-16 text-red-500">
Failed to load processing logs. Please try again.
Failed to load mailbox activity. Please try again.
</div>
) : data && data.items.length > 0 ? (
<div className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Account</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-right">Fetched</th>
<th className="px-4 py-3 text-right">Forwarded</th>
<th className="px-4 py-3 text-right">Failed</th>
<th className="px-4 py-3 text-right">Duration</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.items.map((run) => (
<RunRow key={run.id} run={run} />
))}
</tbody>
</table>
) : accounts && accounts.length > 0 ? (
<>
<div className="space-y-4">
{accounts.map((account) => (
<MailboxCard
key={account.id}
account={account}
runs={runsByAccount[account.id] ?? []}
/>
))}
</div>
{/* Pagination */}
{data.pages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
{/* Pagination for runs (only shown when there are multiple pages) */}
{runsData && runsData.pages > 1 && (
<div className="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-lg">
<span className="text-sm text-gray-500">
Page {data.page} of {data.pages} ({data.total} runs)
Showing page {runsData.page} of {runsData.pages} ({runsData.total} successful pulls)
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
onClick={() => setRunsPage((p) => Math.max(1, p - 1))}
disabled={runsPage === 1}
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</button>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
onClick={() => setRunsPage((p) => Math.min(runsData.pages, p + 1))}
disabled={runsPage === runsData.pages}
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
Next
@@ -277,13 +362,13 @@ export default function LogsPage() {
</div>
</div>
)}
</div>
</>
) : (
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Clock className="mx-auto h-12 w-12 text-gray-300 mb-3" />
<h3 className="text-base font-semibold text-gray-700 mb-1">No processing runs yet</h3>
<h3 className="text-base font-semibold text-gray-700 mb-1">No mail accounts yet</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto">
Logs will appear here once your mail accounts start processing emails.
Add a mail account to start seeing activity here.
</p>
</div>
)}
@@ -292,3 +377,4 @@ export default function LogsPage() {
</AuthGuard>
);
}
+2 -2
View File
@@ -18,7 +18,7 @@ import {
Users,
CreditCard,
Bell,
FileText,
Inbox,
Activity
} from 'lucide-react';
@@ -47,7 +47,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Mail Accounts', href: '/accounts', icon: Mail },
{ name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Logs', href: '/logs', icon: FileText },
{ name: 'Mailbox Activity', href: '/logs', icon: Inbox },
{ name: 'Settings', href: '/settings', icon: Settings },
];
+2 -1
View File
@@ -357,6 +357,7 @@ export const processingRunsApi = {
page_size?: number;
account_id?: number;
status?: string;
has_emails?: boolean;
}): Promise<PaginatedProcessingRuns> {
const response = await api.get<PaginatedProcessingRuns>("/processing-runs", {
params,
@@ -382,7 +383,7 @@ export const processingRunsApi = {
async listForAccount(
accountId: number,
params?: { page?: number; page_size?: number; status?: string }
params?: { page?: number; page_size?: number; status?: string; has_emails?: boolean }
): Promise<PaginatedProcessingRuns> {
const response = await api.get<PaginatedProcessingRuns>(
`/mail-accounts/${accountId}/processing-runs`,