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
+9
View File
@@ -22,6 +22,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Mailbox Activity view**: The user-facing "Logs" page has been redesigned to a mailbox-centric
layout (renamed "Mailbox Activity"). Each mail account is shown as a card with its last check
status and error (if any). Only runs that actually fetched emails are shown in the pull history,
eliminating noise from empty polling cycles. This mirrors Gmail's external POP pull UI.
- **Processing runs filter**: Added `has_emails` query parameter to `GET /processing-runs` and
`GET /mail-accounts/{id}/processing-runs`. When `has_emails=true`, only runs with
`emails_fetched > 0` are returned, allowing clients to suppress empty polling noise.
### Fixed
- **Processing log durations**: Runs that were killed by SIGKILL or failed before updating their
own status (e.g. missing SMTP credentials) now always record a correct `completed_at` and
+9 -1
View File
@@ -61,12 +61,18 @@ async def list_processing_runs(
alias="status",
description="Filter by run status (completed, failed, partial_failure, running)",
),
has_emails: Optional[bool] = Query(
None,
description="When true, only return runs that fetched at least one email",
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Return a paginated list of processing runs for all mail accounts owned by
the authenticated user, optionally filtered by account or status.
the authenticated user, optionally filtered by account, status, or whether
any emails were fetched (has_emails=true reduces log noise by hiding empty
polling cycles).
"""
# Base query: join with MailAccount to enforce ownership
base = (
@@ -79,6 +85,8 @@ async def list_processing_runs(
base = base.where(ProcessingRun.mail_account_id == account_id)
if status_filter:
base = base.where(ProcessingRun.status == status_filter)
if has_emails is True:
base = base.where(ProcessingRun.emails_fetched > 0)
# Total count
count_q = select(func.count()).select_from(base.subquery())
@@ -297,6 +297,10 @@ async def list_account_runs(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
has_emails: Optional[bool] = Query(
None,
description="When true, only return runs that fetched at least one email",
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
@@ -314,6 +318,8 @@ async def list_account_runs(
)
base = select(ProcessingRun).where(ProcessingRun.mail_account_id == account_id)
if has_emails is True:
base = base.where(ProcessingRun.emails_fetched > 0)
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
+2
View File
@@ -4,6 +4,8 @@ Comprehensive task breakdown for repository improvements and production readines
## ✅ Recently Completed
- [x] Redesigned user-facing Logs page to mailbox-centric "Mailbox Activity" view: shows last check status per account + only successful pulls, suppressing noise from empty polling cycles.
- [x] Added `has_emails` filter to `GET /processing-runs` and `GET /mail-accounts/{id}/processing-runs` API endpoints.
- [x] Rename entire project to **InboxConverge**: all user-visible strings, Docker container/image names, DB defaults, monitoring, and docs updated.
- [x] Domain updated to `inboxconverge.com`; contact email defaults to `christian@inboxconverge.com`.
- [x] New configurable env vars: `CONTACT_EMAIL`, `APP_URL`, `NEXT_PUBLIC_APP_NAME`.
+2 -2
View File
@@ -1724,7 +1724,7 @@
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2801,7 +2801,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
+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`,