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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user