From eeb728e000c0bf829a641a96e274b05601f3249a Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Mon, 11 May 2026 20:22:51 +0000 Subject: [PATCH] feat: add connector sync visibility to NOVA settings page - Add SyncStatusBadge component showing syncing/synced/idle status - Add SyncHistorySheet with sync run history (status, trigger, items, errors) - Add useSyncRuns and useTriggerSync hooks - Add sync-runs and import endpoints to API schema - Update ConnectionRow with sync status, sync now button, history button - Add conditional polling (5s when syncing, stop when idle) - Extract shared formatRelativeTime utility --- .../components/settings/connections-mcp.tsx | 169 ++++++++++----- .../settings/sync-history-sheet.tsx | 201 ++++++++++++++++++ .../components/settings/sync-status-badge.tsx | 82 +++++++ apps/web/components/settings/sync-utils.ts | 38 ++++ apps/web/hooks/use-sync-runs.ts | 35 +++ apps/web/hooks/use-trigger-sync.ts | 46 ++++ packages/lib/api.ts | 35 +++ 7 files changed, 552 insertions(+), 54 deletions(-) create mode 100644 apps/web/components/settings/sync-history-sheet.tsx create mode 100644 apps/web/components/settings/sync-status-badge.tsx create mode 100644 apps/web/components/settings/sync-utils.ts create mode 100644 apps/web/hooks/use-sync-runs.ts create mode 100644 apps/web/hooks/use-trigger-sync.ts diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 8c881498..c55ee1f6 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -7,7 +7,7 @@ import { hasActivePlan } from "@lib/queries" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Check, Plus, Trash2, Zap } from "lucide-react" +import { Check, History, Loader2, Play, Plus, Trash2, Zap } from "lucide-react" import { useEffect, useState } from "react" import { toast } from "sonner" import { useQueryState } from "nuqs" @@ -20,6 +20,11 @@ import { RemoveConnectionDialog } from "@/components/remove-connection-dialog" import { addDocumentParam } from "@/lib/search-params" import { DEFAULT_PROJECT_ID } from "@lib/constants" import type { Project } from "@lib/types" +import { SyncStatusBadge } from "@/components/settings/sync-status-badge" +import { SyncHistorySheet } from "@/components/settings/sync-history-sheet" +import { useTriggerSync } from "@/hooks/use-trigger-sync" +import { formatRelativeTime } from "@/components/settings/sync-utils" +import type { ImportProvider } from "@/components/settings/sync-utils" type Connection = z.infer @@ -128,64 +133,29 @@ function PillButton({ ) } -function ConnectionStatusBadge({ connected }: { connected: boolean }) { - return ( -
-
- - {connected ? "Connected" : "Disconnected"} - -
- ) -} - function ConnectionRow({ connection, onDelete, isDeleting, disabled, projects, + onTriggerSync, + isSyncing, + onViewHistory, }: { connection: Connection onDelete: () => void isDeleting: boolean disabled?: boolean projects: Project[] + onTriggerSync: () => void + isSyncing: boolean + onViewHistory: () => void }) { const config = CONNECTORS[connection.provider as ConnectorProvider] if (!config) return null const Icon = config.icon - // Check if connection is active: if expiresAt exists and is in the future, or if no expiresAt - const isConnected = - !connection.expiresAt || new Date(connection.expiresAt) > new Date() - - // Format relative time - const formatRelativeTime = (date: string | null | undefined) => { - if (!date) return "Never" - const d = new Date(date) - const now = new Date() - const diffMs = now.getTime() - d.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - const diffDays = Math.floor(diffHours / 24) - - if (diffHours < 1) return "Just now" - if (diffHours < 24) return `${diffHours}h ago` - if (diffDays === 1) return "Yesterday" - if (diffDays < 7) return `${diffDays} days ago` - return d.toLocaleDateString() - } const getProjectDisplayName = (containerTag: string): string => { if (containerTag === DEFAULT_PROJECT_ID) return "Default Project" @@ -224,7 +194,16 @@ function ConnectionRow({ > {config.title} - + ) + ?.syncInProgress as boolean | undefined + } + lastSyncedAt={ + (connection.metadata as Record) + ?.lastSyncedAt as number | undefined + } + />
- +
+ + + +
{/* Meta row */} @@ -267,7 +279,12 @@ function ConnectionRow({ "font-medium text-[14px] tracking-[-0.14px] text-[#737373]", )} > - Added: {formatRelativeTime(connection.createdAt)} + Last synced:{" "} + {formatRelativeTime( + (connection.metadata as Record)?.lastSyncedAt as + | number + | undefined, + )}
({ open: false, connection: null }) + const triggerSync = useTriggerSync() + const [syncHistorySheet, setSyncHistorySheet] = useState<{ + open: boolean + connection: Connection | null + }>({ open: false, connection: null }) const projects = (queryClient.getQueryData(["projects"]) || []) as Project[] @@ -375,7 +397,17 @@ export default function ConnectionsMCP() { return response.data as Connection[] }, staleTime: 30 * 1000, - refetchInterval: 60 * 1000, + refetchInterval: (query) => { + const conns = query.state.data as Connection[] | undefined + if ( + conns?.some( + (c) => (c.metadata as Record)?.syncInProgress, + ) + ) { + return 5000 + } + return false + }, enabled: hasProProduct, }) @@ -496,6 +528,27 @@ export default function ConnectionsMCP() { isDeleting={deleteConnectionMutation.isPending} disabled={!hasProProduct} projects={projects} + onTriggerSync={() => + triggerSync.mutate({ + connectionId: connection.id, + provider: connection.provider as ImportProvider, + containerTags: ( + connection as Connection & { + containerTags?: string[] + } + ).containerTags, + }) + } + isSyncing={ + (triggerSync.isPending && + triggerSync.variables?.connectionId === + connection.id) || + !!(connection.metadata as Record) + ?.syncInProgress + } + onViewHistory={() => + setSyncHistorySheet({ open: true, connection }) + } /> )) ) : ( @@ -594,6 +647,14 @@ export default function ConnectionsMCP() { }} isDeleting={deleteConnectionMutation.isPending} /> + + { + if (!open) setSyncHistorySheet({ open: false, connection: null }) + }} + connection={syncHistorySheet.connection} + />
) } diff --git a/apps/web/components/settings/sync-history-sheet.tsx b/apps/web/components/settings/sync-history-sheet.tsx new file mode 100644 index 00000000..25c015f7 --- /dev/null +++ b/apps/web/components/settings/sync-history-sheet.tsx @@ -0,0 +1,201 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@ui/components/sheet" +import { useSyncRuns } from "@/hooks/use-sync-runs" +import type { SyncRun } from "@/hooks/use-sync-runs" +import { + formatRelativeTime, + TRIGGER_TYPE_LABELS, +} from "@/components/settings/sync-utils" +import type { ConnectionResponseSchema } from "@repo/validation/api" +import type { z } from "zod" + +type Connection = z.infer + +const PROVIDER_TITLES: Record = { + "google-drive": "Google Drive", + notion: "Notion", + onedrive: "OneDrive", + gmail: "Gmail", + github: "GitHub", + "web-crawler": "Web Crawler", + s3: "S3", +} + +const STATUS_COLORS: Record = { + completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" }, + failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" }, + running: { dot: "bg-[#4BA0FA] animate-pulse", text: "text-[#4BA0FA]" }, +} + +function SyncRunCard({ run }: { run: SyncRun }) { + const colors = STATUS_COLORS[run.status] ?? { + dot: "bg-[#4BA0FA] animate-pulse", + text: "text-[#4BA0FA]", + } + + return ( +
+ {/* Top row: status + trigger + time */} +
+
+
+ + {run.status.charAt(0).toUpperCase() + run.status.slice(1)} + + + {TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType} + +
+ + {formatRelativeTime(run.startedAt)} + +
+ + {/* Middle row: item counts */} + {(run.itemsProcessed > 0 || run.itemsFailed > 0) && ( +
+ {run.itemsProcessed} processed + {run.itemsFailed > 0 && ( + · {run.itemsFailed} failed + )} +
+ )} + + {/* Bottom row: error message */} + {run.error && ( +

+ {run.error} +

+ )} +
+ ) +} + +interface SyncHistorySheetProps { + open: boolean + onOpenChange: (open: boolean) => void + connection: Connection | null +} + +export function SyncHistorySheet({ + open, + onOpenChange, + connection, +}: SyncHistorySheetProps) { + const { + data: syncRuns, + isLoading, + error, + refetch, + } = useSyncRuns(open && connection ? connection.id : "") + + const providerTitle = connection + ? (PROVIDER_TITLES[connection.provider] ?? connection.provider) + : "" + const email = connection?.email ?? "" + + return ( + + + + + Sync History + + + {providerTitle} + {email ? ` · ${email}` : ""} + + + +
+ {isLoading && ( +
+
+
+ )} + + {error && !isLoading && ( +
+

+ Failed to load sync history +

+ +
+ )} + + {!isLoading && !error && syncRuns && syncRuns.length === 0 && ( +
+

+ No sync runs yet +

+
+ )} + + {!isLoading && + !error && + syncRuns && + syncRuns.length > 0 && + syncRuns.map((run) => )} +
+ + + ) +} diff --git a/apps/web/components/settings/sync-status-badge.tsx b/apps/web/components/settings/sync-status-badge.tsx new file mode 100644 index 00000000..878106b1 --- /dev/null +++ b/apps/web/components/settings/sync-status-badge.tsx @@ -0,0 +1,82 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { formatRelativeTime } from "@/components/settings/sync-utils" + +function deriveStatus( + syncInProgress?: boolean, + lastSyncedAt?: number, +): "syncing" | "synced" | "idle" { + if (syncInProgress) return "syncing" + if (lastSyncedAt) return "synced" + return "idle" +} + +interface SyncStatusBadgeProps { + syncInProgress?: boolean + lastSyncedAt?: number + className?: string +} + +export function SyncStatusBadge({ + syncInProgress, + lastSyncedAt, + className, +}: SyncStatusBadgeProps) { + const status = deriveStatus(syncInProgress, lastSyncedAt) + + return ( +
+
+ {status === "syncing" && ( + + Syncing... + + )} + {status === "synced" && ( + <> + + Synced + +
+ + {formatRelativeTime(lastSyncedAt)} + + + )} + {status === "idle" && ( + + Waiting for first sync + + )} +
+ ) +} diff --git a/apps/web/components/settings/sync-utils.ts b/apps/web/components/settings/sync-utils.ts new file mode 100644 index 00000000..b560da70 --- /dev/null +++ b/apps/web/components/settings/sync-utils.ts @@ -0,0 +1,38 @@ +/** + * Format a date/timestamp into a human-readable relative time string. + * Accepts ISO string, epoch milliseconds (number), Date object, or null/undefined. + */ +export function formatRelativeTime( + date: string | number | Date | null | undefined, +): string { + if (!date) return "Never" + const d = typeof date === "number" ? new Date(date) : new Date(date) + if (Number.isNaN(d.getTime())) return "Never" + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + + if (diffHours < 1) return "Just now" + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString() +} + +/** Map backend trigger type enum to user-facing display label */ +export const TRIGGER_TYPE_LABELS: Record = { + event: "Webhook", + cron: "Scheduled", + manual: "Manual", +} + +/** Provider type union matching the backend import endpoint */ +export type ImportProvider = + | "google-drive" + | "notion" + | "onedrive" + | "gmail" + | "github" + | "web-crawler" + | "s3" diff --git a/apps/web/hooks/use-sync-runs.ts b/apps/web/hooks/use-sync-runs.ts new file mode 100644 index 00000000..9c4469d0 --- /dev/null +++ b/apps/web/hooks/use-sync-runs.ts @@ -0,0 +1,35 @@ +"use client" + +import { $fetch } from "@lib/api" +import { useQuery } from "@tanstack/react-query" + +export type SyncRun = { + id: string + connectionId: string + status: "running" | "completed" | "failed" + triggerType: "event" | "cron" | "manual" + startedAt: string + completedAt: string | null + itemsProcessed: number + itemsFailed: number + error: string | null +} + +export function useSyncRuns(connectionId: string) { + return useQuery({ + queryKey: ["sync-runs", connectionId], + queryFn: async () => { + const response = await $fetch( + "@get/connections/:connectionId/sync-runs", + { params: { connectionId } }, + ) + if (response.error) { + throw new Error("Failed to fetch sync runs") + } + return response.data as SyncRun[] + }, + enabled: !!connectionId, + staleTime: 30 * 1000, + refetchOnMount: "always", + }) +} diff --git a/apps/web/hooks/use-trigger-sync.ts b/apps/web/hooks/use-trigger-sync.ts new file mode 100644 index 00000000..149f4a2b --- /dev/null +++ b/apps/web/hooks/use-trigger-sync.ts @@ -0,0 +1,46 @@ +"use client" + +import { $fetch } from "@lib/api" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import type { ImportProvider } from "@/components/settings/sync-utils" + +export function useTriggerSync() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + provider, + containerTags, + }: { + connectionId: string + provider: ImportProvider + containerTags?: string[] + }) => { + const response = await $fetch("@post/connections/:provider/import", { + params: { provider }, + body: { containerTags }, + }) + if (response.error) { + throw new Error( + (response.error as { message?: string })?.message || + "Failed to trigger sync", + ) + } + return response.data + }, + onSuccess: (_data, variables) => { + toast.success("Sync started") + queryClient.invalidateQueries({ queryKey: ["connections"] }) + queryClient.invalidateQueries({ + queryKey: ["sync-runs", variables.connectionId], + }) + queryClient.invalidateQueries({ queryKey: ["processing-documents"] }) + }, + onError: (error) => { + toast.error("Failed to start sync", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) +} diff --git a/packages/lib/api.ts b/packages/lib/api.ts index f2d13b47..5f2b9e3f 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -125,6 +125,41 @@ export const apiSchema = createSchema({ }), }, + "@get/connections/:connectionId/sync-runs": { + output: z.array( + z.object({ + id: z.string(), + connectionId: z.string(), + status: z.enum(["running", "completed", "failed"]), + triggerType: z.enum(["event", "cron", "manual"]), + startedAt: z.string(), + completedAt: z.string().nullable(), + itemsProcessed: z.number(), + itemsFailed: z.number(), + error: z.string().nullable(), + }), + ), + params: z.object({ connectionId: z.string() }), + }, + + "@post/connections/:provider/import": { + input: z.object({ + containerTags: z.array(z.string()).optional(), + }), + output: z.any(), + params: z.object({ + provider: z.enum([ + "google-drive", + "notion", + "onedrive", + "gmail", + "github", + "web-crawler", + "s3", + ]), + }), + }, + // Settings operations "@get/settings": { output: z.object({ settings: z.object({}).passthrough() }),