email list sync button uses new email-sync handler, job duration logging

Rewired EmailList sync button to call /email/accounts/:id/sync instead
of the old gmail-sync job. Shows sync button for connected and synced
accounts. Allow manual incremental sync for synced accounts.

Added duration logging to queue runner: start/complete/fail markers
with elapsed time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 07:25:26 +00:00
co-authored by Claude Opus 4.6
parent 4ececbe748
commit bd7ece80cf
3 changed files with 58 additions and 38 deletions
@@ -6,14 +6,13 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { useJobs } from 'hooks/useJobs';
import type { EmailSummary } from 'types'; import type { EmailSummary } from 'types';
type GoogleStatus = { type EmailAccountRow = {
configured: boolean; id: number;
connected: boolean; provider: string;
email: string | null; email: string;
picture: string | null; status: string;
}; };
const LIMIT = 50; const LIMIT = 50;
@@ -42,13 +41,13 @@ export const EmailList = () => {
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null); const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox'); const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const { jobs, createJob } = useJobs({ type: 'gmail-sync' }); const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running'); queryKey: ['email-accounts'],
queryFn: () => client.get<EmailAccountRow[]>('/email/accounts'),
const { data: googleStatus } = useQuery({
queryKey: ['google-status'],
queryFn: () => client.get<GoogleStatus>('/integrations/google/status'),
}); });
const syncableAccount = emailAccounts.find((a) => a.status === 'connected' || a.status === 'synced');
const isSyncing = emailAccounts.some((a) => a.status === 'syncing' || a.status === 'queued');
const hasAccounts = emailAccounts.length > 0;
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['email-messages', page, folder], queryKey: ['email-messages', page, folder],
@@ -75,17 +74,23 @@ export const EmailList = () => {
}; };
const handleSync = async () => { const handleSync = async () => {
if (!syncableAccount) return;
try { try {
await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false }); await client.post(`/email/accounts/${syncableAccount.id}/sync`, {});
toast.success('Gmail sync started'); toast.success('Sync started');
} catch { refetchAccounts();
toast.error('Failed to start sync'); } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to start sync');
} }
}; };
// Refresh email list when a sync job completes // Poll accounts while syncing, refresh email list when done
const prevSyncing = useRef(false); const prevSyncing = useRef(false);
useEffect(() => { useEffect(() => {
if (isSyncing) {
const interval = setInterval(refetchAccounts, 5000);
return () => clearInterval(interval);
}
if (prevSyncing.current && !isSyncing) { if (prevSyncing.current && !isSyncing) {
queryClient.invalidateQueries({ queryKey: ['email-messages'] }); queryClient.invalidateQueries({ queryKey: ['email-messages'] });
} }
@@ -134,16 +139,14 @@ export const EmailList = () => {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50"> <div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
<Mail className="h-8 w-8" /> <Mail className="h-8 w-8" />
{googleStatus && !googleStatus.configured ? ( {!hasAccounts ? (
<span>Google integration not configured. Contact your administrator.</span>
) : googleStatus && !googleStatus.connected ? (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span>Connect your Google account to sync emails</span> <span>Add an email account to get started</span>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link to="/settings/integrations">Connect Google</Link> <Link to="/settings/integrations">Add Account</Link>
</Button> </Button>
</div> </div>
) : ( ) : syncableAccount ? (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span>No emails synced yet</span> <span>No emails synced yet</span>
<Button variant="outline" size="sm" onClick={handleSync} disabled={isSyncing}> <Button variant="outline" size="sm" onClick={handleSync} disabled={isSyncing}>
@@ -151,6 +154,13 @@ export const EmailList = () => {
Sync Now Sync Now
</Button> </Button>
</div> </div>
) : isSyncing ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Syncing emails...</span>
</div>
) : (
<span>No emails synced yet</span>
)} )}
</div> </div>
); );
@@ -179,18 +189,17 @@ export const EmailList = () => {
})} })}
</div> </div>
<span className="text-xs opacity-50">{total}</span> <span className="text-xs opacity-50">{total}</span>
<button {isSyncing ? (
onClick={handleSync} <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 opacity-50" title="Syncing..." />
disabled={isSyncing} ) : syncableAccount ? (
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50 shrink-0" <button
title={isSyncing ? 'Syncing...' : 'Sync emails'} onClick={handleSync}
> className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
{isSyncing ? ( title="Sync emails"
<Loader2 className="h-3.5 w-3.5 animate-spin" /> >
) : (
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
)} </button>
</button> ) : null}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<button <button
-1
View File
@@ -135,7 +135,6 @@ accountsRouter.post('/:id/sync', async (ctx) => {
if (account.status === 'queued') throw BAD_REQUEST('Sync is already queued'); if (account.status === 'queued') throw BAD_REQUEST('Sync is already queued');
if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing'); if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing');
if (account.status === 'synced') throw BAD_REQUEST('Account is already synced — incremental syncs run automatically');
// Resolve auth before enqueueing // Resolve auth before enqueueing
const authResult = await resolveAuth(user.id, account.authType, account.email, account.credentials as Record<string, unknown>); const authResult = await resolveAuth(user.id, account.authType, account.email, account.credentials as Record<string, unknown>);
+15 -3
View File
@@ -5,6 +5,17 @@ import { getHandler } from '../queue/handler-registry';
// Import handlers to register them // Import handlers to register them
import '../queue/handlers'; import '../queue/handlers';
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return rem > 0 ? `${m}m${rem}s` : `${m}m`;
const h = Math.floor(m / 60);
const remM = m % 60;
return remM > 0 ? `${h}h${remM}m` : `${h}h`;
}
const activeLanes = new Map<string, boolean>(); const activeLanes = new Map<string, boolean>();
const PROGRESS_THROTTLE_MS = 1000; const PROGRESS_THROTTLE_MS = 1000;
@@ -142,8 +153,9 @@ async function runJob(job: Job) {
job.retryAt = undefined; job.retryAt = undefined;
await writeJob(job); await writeJob(job);
const isRetry = (job.retries ?? 0) > 0; const isRetry = (job.retries ?? 0) > 0;
const startTime = Date.now();
console.log( console.log(
`[sidecar:queue] ${isRetry ? 'resuming' : 'running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`, `[sidecar:queue] ${isRetry ? 'resuming' : 'running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`,
); );
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) }; const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
@@ -225,7 +237,7 @@ async function runJob(job: Job) {
fresh.completedAt = Date.now(); fresh.completedAt = Date.now();
fresh.meta = { ...fresh.meta, ...sharedMeta }; fresh.meta = { ...fresh.meta, ...sharedMeta };
await writeJob(fresh); await writeJob(fresh);
console.error(`[sidecar:queue] job ${fresh.id} failed at step "${step.name}":`, errorMessage); console.error(`[sidecar:queue] job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`, errorMessage);
await notifyFailure(fresh); await notifyFailure(fresh);
return; return;
} }
@@ -237,7 +249,7 @@ async function runJob(job: Job) {
final.completedAt = Date.now(); final.completedAt = Date.now();
final.meta = { ...final.meta, ...sharedMeta }; final.meta = { ...final.meta, ...sharedMeta };
await writeJob(final); await writeJob(final);
console.log(`[sidecar:queue] job ${final.id} completed`); console.log(`[sidecar:queue] job ${final.id} completed in ${formatDuration(Date.now() - startTime)}`);
await notifyCompletion(final); await notifyCompletion(final);
} }
} }