feat: add connector sync visibility to NOVA settings page (#930)

Co-authored-by: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com>
Co-authored-by: Mahesh Sanikommu <maheshthedev@gmail.com>
This commit is contained in:
vorflux[bot] 2026-05-12 11:59:56 +05:30 committed by GitHub
parent 5a0dffbc4c
commit 68a7781de7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 879 additions and 120 deletions

View file

@ -9,9 +9,11 @@ import { useCustomer } from "autumn-js/react"
import {
Check,
ChevronDown,
Clock,
FolderOpen,
History,
Loader,
Loader2,
Play,
Trash2,
Zap,
} from "lucide-react"
@ -30,6 +32,11 @@ import {
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
import { useTriggerSync } from "@/hooks/use-trigger-sync"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
type GDriveSyncScope = "scoped" | "full"
@ -71,17 +78,20 @@ const CONNECTORS: Record<
},
} as const
function formatRelativeTime(date: string | null | undefined): string {
if (!date) return "Never"
const d = new Date(date)
const diffMs = Date.now() - 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()
/** Extract typed metadata from a connection, with runtime validation. */
function getConnectionMeta(connection: Connection) {
const m = connection.metadata as Record<string, unknown> | undefined
return {
syncInProgress: m?.syncInProgress === true,
lastSyncedAt:
typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined,
documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0,
}
}
/** Check if a connection's auth token has expired. */
function isConnectionExpired(connection: Connection): boolean {
return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date()
}
function ConnectionRow({
@ -89,18 +99,23 @@ function ConnectionRow({
onDelete,
isDeleting,
projects,
onTriggerSync,
isSyncing,
}: {
connection: Connection
onDelete: () => void
isDeleting: boolean
projects: Project[]
onTriggerSync: () => void
isSyncing: boolean
}) {
const [historyOpen, setHistoryOpen] = useState(false)
const config = CONNECTORS[connection.provider as ConnectorProvider]
if (!config) return null
const Icon = config.icon
const isConnected =
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
const meta = getConnectionMeta(connection)
const expired = isConnectionExpired(connection)
const getProjectName = (tag: string): string => {
if (tag === DEFAULT_PROJECT_ID) return "Default"
@ -110,12 +125,8 @@ function ConnectionRow({
)
}
const documentCount = (connection.metadata?.documentCount as number) ?? 0
const containerTags = (
connection as Connection & { containerTags?: string[] }
).containerTags
const projectName = containerTags?.[0]
? getProjectName(containerTags[0])
const projectName = connection.containerTags?.[0]
? getProjectName(connection.containerTags[0])
: null
return (
@ -138,23 +149,11 @@ function ConnectionRow({
>
{config.title}
</span>
<div className="flex items-center gap-2">
<div
className={cn(
"size-[7px] rounded-full",
isConnected ? "bg-[#00AC3F]" : "bg-[#737373]",
)}
/>
<span
className={cn(
dmSans125ClassName(),
"text-[14px]",
isConnected ? "text-[#00AC3F]" : "text-[#737373]",
)}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
<SyncStatusBadge
syncInProgress={meta.syncInProgress}
lastSyncedAt={meta.lastSyncedAt}
isExpired={expired}
/>
</div>
<span
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
@ -162,14 +161,71 @@ function ConnectionRow({
{connection.email || "Unknown"}
</span>
</div>
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="size-[22px]" />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onTriggerSync()
}}
disabled={isSyncing || expired}
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label={
expired
? "Connection expired"
: isSyncing
? "Sync in progress"
: "Sync now"
}
title={
expired
? "Reconnect to sync"
: isSyncing
? "Sync in progress"
: "Sync now"
}
>
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setHistoryOpen((v) => !v)
}}
aria-label="Sync history"
aria-expanded={historyOpen}
title={historyOpen ? "Hide sync history" : "Sync history"}
className={cn(
"transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5 flex items-center gap-0.5",
historyOpen
? "text-[#FAFAFA] bg-white/5"
: "text-[#737373] hover:text-[#FAFAFA]",
)}
>
<History className="size-[18px]" />
<ChevronDown
className={cn(
"size-3 transition-transform",
historyOpen && "rotate-180",
)}
/>
</button>
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label="Delete connection"
title="Remove connection"
>
<Trash2 className="size-[18px]" />
</button>
</div>
</div>
<div className="flex items-center gap-3 pt-2.5 border-t border-[rgba(82,89,102,0.12)]">
<div className="flex items-center gap-2 flex-1 flex-wrap">
@ -186,17 +242,11 @@ function ConnectionRow({
</span>
</div>
)}
<div className="flex items-center gap-1">
<Clock className="size-3 text-[#4B5563]" />
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373]",
)}
>
{formatRelativeTime(connection.createdAt)}
</span>
</div>
<span
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
>
Last synced: {formatRelativeTime(meta.lastSyncedAt)}
</span>
</div>
<div className="flex items-baseline gap-1 shrink-0">
<span
@ -205,7 +255,7 @@ function ConnectionRow({
"text-[14px] font-semibold text-[#FAFAFA]",
)}
>
{documentCount}
{meta.documentCount}
</span>
<span
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
@ -214,6 +264,15 @@ function ConnectionRow({
</span>
</div>
</div>
{historyOpen && (
<div className="border-t border-[rgba(82,89,102,0.12)] pt-3">
<SyncHistoryPanel
connectionId={connection.id}
isOpen={historyOpen}
/>
</div>
)}
</div>
</div>
)
@ -236,6 +295,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
open: boolean
connection: Connection | null
}>({ open: false, connection: null })
const triggerSync = useTriggerSync()
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
[]) as Project[]
@ -282,7 +342,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
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) => getConnectionMeta(c).syncInProgress)) {
return 5000
}
return 60 * 1000
},
refetchIntervalInBackground: true,
})
@ -644,6 +710,18 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
projects={projects}
onDelete={() => setRemoveDialog({ open: true, connection })}
isDeleting={deleteConnectionMutation.isPending}
onTriggerSync={() =>
triggerSync.mutate({
connectionId: connection.id,
provider: connection.provider as ImportProvider,
containerTags: connection.containerTags,
})
}
isSyncing={
(triggerSync.isPending &&
triggerSync.variables?.connectionId === connection.id) ||
getConnectionMeta(connection).syncInProgress
}
/>
))}
</div>

View file

@ -7,7 +7,16 @@ 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,
ChevronDown,
History,
Loader2,
Play,
Plus,
Trash2,
Zap,
} from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { useQueryState } from "nuqs"
@ -20,9 +29,30 @@ 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 { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
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<typeof ConnectionResponseSchema>
/** Extract typed metadata from a connection, with runtime validation. */
function getConnectionMeta(connection: Connection) {
const m = connection.metadata as Record<string, unknown> | undefined
return {
syncInProgress: m?.syncInProgress === true,
lastSyncedAt:
typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined,
documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0,
}
}
/** Check if a connection's auth token has expired. */
function isConnectionExpired(connection: Connection): boolean {
return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date()
}
const CONNECTORS = {
"google-drive": {
title: "Google Drive",
@ -128,64 +158,30 @@ function PillButton({
)
}
function ConnectionStatusBadge({ connected }: { connected: boolean }) {
return (
<div className="flex items-center gap-2">
<div
className={cn(
"size-[7px] rounded-full",
connected ? "bg-[#00AC3F]" : "bg-[#737373]",
)}
/>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px]",
connected ? "text-[#00AC3F]" : "text-[#737373]",
)}
>
{connected ? "Connected" : "Disconnected"}
</span>
</div>
)
}
function ConnectionRow({
connection,
onDelete,
isDeleting,
disabled,
projects,
onTriggerSync,
isSyncing,
}: {
connection: Connection
onDelete: () => void
isDeleting: boolean
disabled?: boolean
projects: Project[]
onTriggerSync: () => void
isSyncing: boolean
}) {
const [historyOpen, setHistoryOpen] = useState(false)
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 meta = getConnectionMeta(connection)
const expired = isConnectionExpired(connection)
const getProjectDisplayName = (containerTag: string): string => {
if (containerTag === DEFAULT_PROJECT_ID) return "Default Project"
@ -194,13 +190,11 @@ function ConnectionRow({
return containerTag.replace(/^sm_project_/, "") // if cached project is not found, remove the prefix
}
const documentCount = (connection.metadata?.documentCount as number) ?? 0
const containerTags = (
connection as Connection & { containerTags?: string[] }
).containerTags
const projectName =
containerTags && containerTags.length > 0 && containerTags[0]
? getProjectDisplayName(containerTags[0])
connection.containerTags &&
connection.containerTags.length > 0 &&
connection.containerTags[0]
? getProjectDisplayName(connection.containerTags[0])
: null
return (
@ -224,7 +218,11 @@ function ConnectionRow({
>
{config.title}
</span>
<ConnectionStatusBadge connected={isConnected} />
<SyncStatusBadge
syncInProgress={meta.syncInProgress}
lastSyncedAt={meta.lastSyncedAt}
isExpired={expired}
/>
</div>
<span
className={cn(
@ -235,15 +233,72 @@ function ConnectionRow({
{connection.email || "Unknown"}
</span>
</div>
<button
type="button"
onClick={onDelete}
disabled={isDeleting || disabled}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Delete connection"
>
<Trash2 className="size-[22px]" />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onTriggerSync()
}}
disabled={isSyncing || disabled || expired}
className="text-[#737373] hover:text-[#4BA0FA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label={
expired
? "Connection expired"
: isSyncing
? "Sync in progress"
: "Sync now"
}
title={
expired
? "Reconnect to sync"
: isSyncing
? "Sync in progress"
: "Sync now"
}
>
{isSyncing ? (
<Loader2 className="size-[18px] animate-spin" />
) : (
<Play className="size-[18px]" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setHistoryOpen((v) => !v)
}}
disabled={disabled}
aria-label="Sync history"
aria-expanded={historyOpen}
title={historyOpen ? "Hide sync history" : "Sync history"}
className={cn(
"transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5 flex items-center gap-0.5",
historyOpen
? "text-[#FAFAFA] bg-white/5"
: "text-[#737373] hover:text-[#FAFAFA]",
)}
>
<History className="size-[18px]" />
<ChevronDown
className={cn(
"size-3 transition-transform",
historyOpen && "rotate-180",
)}
/>
</button>
<button
type="button"
onClick={onDelete}
disabled={isDeleting || disabled}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
aria-label="Delete connection"
title="Remove connection"
>
<Trash2 className="size-[18px]" />
</button>
</div>
</div>
{/* Meta row */}
@ -267,7 +322,7 @@ function ConnectionRow({
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
)}
>
Added: {formatRelativeTime(connection.createdAt)}
Last synced: {formatRelativeTime(meta.lastSyncedAt)}
</span>
<div className="size-[3px] rounded-full bg-[#737373]" />
<span
@ -276,9 +331,18 @@ function ConnectionRow({
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
)}
>
{documentCount} {config.documentLabel} connected
{meta.documentCount} {config.documentLabel} connected
</span>
</div>
{historyOpen && (
<div className="border-t border-[rgba(82,89,102,0.15)] pt-4">
<SyncHistoryPanel
connectionId={connection.id}
isOpen={historyOpen}
/>
</div>
)}
</div>
</div>
)
@ -342,6 +406,7 @@ export default function ConnectionsMCP() {
open: boolean
connection: Connection | null
}>({ open: false, connection: null })
const triggerSync = useTriggerSync()
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
[]) as Project[]
@ -375,7 +440,13 @@ 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) => getConnectionMeta(c).syncInProgress)) {
return 5000
}
return 60 * 1000
},
enabled: hasProProduct,
})
@ -496,6 +567,19 @@ export default function ConnectionsMCP() {
isDeleting={deleteConnectionMutation.isPending}
disabled={!hasProProduct}
projects={projects}
onTriggerSync={() =>
triggerSync.mutate({
connectionId: connection.id,
provider: connection.provider as ImportProvider,
containerTags: connection.containerTags,
})
}
isSyncing={
(triggerSync.isPending &&
triggerSync.variables?.connectionId ===
connection.id) ||
getConnectionMeta(connection).syncInProgress
}
/>
))
) : (
@ -582,7 +666,9 @@ export default function ConnectionsMCP() {
}}
provider={removeDialog.connection?.provider}
documentCount={
(removeDialog.connection?.metadata?.documentCount as number) ?? 0
removeDialog.connection
? getConnectionMeta(removeDialog.connection).documentCount
: 0
}
onConfirm={(deleteDocuments) => {
if (removeDialog.connection) {

View file

@ -0,0 +1,267 @@
"use client"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
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"
const STATUS_COLORS: Record<string, { dot: string; text: string }> = {
completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" },
failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" },
running: { dot: "bg-[#4BA0FA] animate-pulse", text: "text-[#4BA0FA]" },
}
function pluralize(count: number, noun: string) {
return `${count} ${noun}${count === 1 ? "" : "s"}`
}
/** Calendar-day bucket label for grouping runs in the timeline. */
function dayLabel(date: string) {
const d = new Date(date)
if (Number.isNaN(d.getTime())) return "Unknown"
const today = new Date()
const startOfDay = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
const diffDays = Math.round((startOfDay(today) - startOfDay(d)) / 86_400_000)
if (diffDays <= 0) return "Today"
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return `${diffDays} days ago`
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
function StatTile({ value, label }: { value: string; label: string }) {
return (
<div className="flex-1 bg-[#0D0F14] rounded-[10px] border border-[rgba(82,89,102,0.2)] px-3 py-2 flex flex-col gap-0.5">
<span
className={cn(
dmSans125ClassName(),
"text-[15px] font-medium text-[#FAFAFA] tabular-nums",
)}
>
{value}
</span>
<span className={cn(dmSans125ClassName(), "text-[11px] text-[#737373]")}>
{label}
</span>
</div>
)
}
function SummaryStats({ runs }: { runs: SyncRun[] }) {
const totalItems = runs.reduce((sum, r) => sum + r.itemsProcessed, 0)
const finished = runs.filter((r) => r.status !== "running")
const succeeded = finished.filter((r) => r.status === "completed").length
const successRate =
finished.length > 0 ? Math.round((succeeded / finished.length) * 100) : null
return (
<div className="flex items-stretch gap-2">
<StatTile
value={String(runs.length)}
label={runs.length === 1 ? "sync" : "syncs"}
/>
<StatTile
value={String(totalItems)}
label={totalItems === 1 ? "item" : "items"}
/>
<StatTile
value={successRate === null ? "—" : `${successRate}%`}
label="success"
/>
</div>
)
}
function TimelineRow({ run, isLast }: { run: SyncRun; isLast: boolean }) {
const colors = STATUS_COLORS[run.status] ?? {
dot: "bg-[#4BA0FA] animate-pulse",
text: "text-[#4BA0FA]",
}
const triggerLabel = TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType
const statusLabel = run.status.charAt(0).toUpperCase() + run.status.slice(1)
return (
<div className="flex gap-2.5">
{/* Timeline rail: dot + connecting line */}
<div className="flex flex-col items-center pt-1.5">
<div className={cn("size-[7px] rounded-full shrink-0", colors.dot)} />
{!isLast && <div className="w-px flex-1 bg-[#1A1D24] mt-1" />}
</div>
{/* Content */}
<div className={cn("flex-1 min-w-0", isLast ? "pb-0" : "pb-3")}>
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5 min-w-0">
<span
className={cn(
dmSans125ClassName(),
"text-[13px] font-medium shrink-0",
colors.text,
)}
>
{statusLabel}
</span>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] truncate",
)}
>
· {triggerLabel}
</span>
</div>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] shrink-0",
)}
>
{formatRelativeTime(run.startedAt)}
</span>
</div>
{(run.itemsProcessed > 0 || run.itemsFailed > 0) && (
<div
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] mt-0.5",
)}
>
{pluralize(run.itemsProcessed, "item")} processed
{run.itemsFailed > 0 && (
<span className="text-[#EF4444]">
{" "}
· {run.itemsFailed} failed
</span>
)}
</div>
)}
{run.error && (
<p
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#EF4444]/80 break-words line-clamp-3 mt-0.5",
)}
>
{run.error}
</p>
)}
</div>
</div>
)
}
function Timeline({ runs }: { runs: SyncRun[] }) {
// Group consecutive runs by calendar-day label, preserving server order.
const groups: { label: string; runs: SyncRun[] }[] = []
for (const run of runs) {
const label = dayLabel(run.startedAt)
const last = groups.at(-1)
if (last && last.label === label) {
last.runs.push(run)
} else {
groups.push({ label, runs: [run] })
}
}
return (
<div className="flex flex-col gap-3">
{groups.map((group, gi) => (
<div key={`${group.label}-${gi}`} className="flex flex-col gap-1.5">
<span
className={cn(
dmSans125ClassName(),
"text-[10px] uppercase tracking-wide text-[#525966]",
)}
>
{group.label}
</span>
<div className="flex flex-col">
{group.runs.map((run, i) => (
<TimelineRow
key={run.id}
run={run}
isLast={i === group.runs.length - 1}
/>
))}
</div>
</div>
))}
</div>
)
}
interface SyncHistoryPanelProps {
connectionId: string
/** Only fetch / render when expanded. */
isOpen: boolean
}
/** Inline sync-history view (stats strip + timeline) rendered inside an expanded connection row. */
export function SyncHistoryPanel({
connectionId,
isOpen,
}: SyncHistoryPanelProps) {
const {
data: syncRuns,
isLoading,
error,
refetch,
} = useSyncRuns(isOpen ? connectionId : "")
if (!isOpen) return null
const hasRuns = !isLoading && !error && syncRuns && syncRuns.length > 0
return (
<div className="flex flex-col gap-3">
{isLoading && (
<div className="flex items-center justify-center py-6">
<div className="size-5 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
</div>
)}
{error && !isLoading && (
<div className="flex items-center justify-center gap-2 py-6">
<span
className={cn(dmSans125ClassName(), "text-[13px] text-[#737373]")}
>
Failed to load sync history
</span>
<button
type="button"
onClick={() => refetch()}
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#4BA0FA] hover:text-[#4BA0FA]/80 underline cursor-pointer",
)}
>
Try again
</button>
</div>
)}
{!isLoading && !error && syncRuns && syncRuns.length === 0 && (
<div className="flex items-center justify-center py-6">
<span
className={cn(dmSans125ClassName(), "text-[13px] text-[#737373]")}
>
No syncs yet runs will appear here.
</span>
</div>
)}
{hasRuns && (
<>
<SummaryStats runs={syncRuns} />
<Timeline runs={syncRuns} />
</>
)}
</div>
)
}

View file

@ -0,0 +1,95 @@
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { formatRelativeTime } from "@/components/settings/sync-utils"
function deriveStatus(
syncInProgress?: boolean,
lastSyncedAt?: number,
isExpired?: boolean,
): "syncing" | "synced" | "expired" | "idle" {
if (isExpired) return "expired"
if (syncInProgress) return "syncing"
if (lastSyncedAt) return "synced"
return "idle"
}
interface SyncStatusBadgeProps {
syncInProgress?: boolean
lastSyncedAt?: number
isExpired?: boolean
className?: string
}
export function SyncStatusBadge({
syncInProgress,
lastSyncedAt,
isExpired,
className,
}: SyncStatusBadgeProps) {
const status = deriveStatus(syncInProgress, lastSyncedAt, isExpired)
return (
<div className={cn("flex items-center gap-1.5", className)}>
<div
className={cn(
"size-[6px] rounded-full",
status === "syncing" && "bg-[#4BA0FA] animate-pulse",
status === "synced" && "bg-[#00AC3F]",
status === "expired" && "bg-[#EF4444]",
status === "idle" && "bg-[#737373]",
)}
/>
{status === "syncing" && (
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[13px] tracking-[-0.13px] text-[#4BA0FA]",
)}
>
Syncing...
</span>
)}
{status === "synced" && (
<>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[13px] tracking-[-0.13px] text-[#00AC3F]",
)}
>
Synced
</span>
<div className="size-[3px] rounded-full bg-[#737373]" />
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
{formatRelativeTime(lastSyncedAt)}
</span>
</>
)}
{status === "expired" && (
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[13px] tracking-[-0.13px] text-[#EF4444]",
)}
>
Disconnected
</span>
)}
{status === "idle" && (
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Waiting for first sync
</span>
)}
</div>
)
}

View file

@ -0,0 +1,55 @@
/**
* 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()
// Handle future dates (e.g. slight server clock skew)
if (diffMs < 0) return "Just now"
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffMinutes < 1) return "Just now"
if (diffMinutes < 60) return `${diffMinutes}m ago`
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<string, string> = {
event: "Webhook",
cron: "Scheduled",
manual: "Manual",
}
/** Canonical provider → display name map. Import this everywhere instead of duplicating. */
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"google-drive": "Google Drive",
notion: "Notion",
onedrive: "OneDrive",
gmail: "Gmail",
github: "GitHub",
"web-crawler": "Web Crawler",
s3: "S3",
}
/** Provider type union matching the backend import endpoint */
export type ImportProvider =
| "google-drive"
| "notion"
| "onedrive"
| "gmail"
| "github"
| "web-crawler"
| "s3"

View file

@ -0,0 +1,46 @@
"use client"
import { $fetch } from "@lib/api"
import { useQuery } from "@tanstack/react-query"
/**
* Mirrors the Zod schema at `apiSchema["@get/connections/:connectionId/sync-runs"].output`.
* Keep in sync with `packages/lib/api.ts` if fields are added/removed.
*/
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<SyncRun[]>({
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",
refetchInterval: (query) => {
const runs = query.state.data as SyncRun[] | undefined
if (runs?.some((r) => r.status === "running")) {
return 5000
}
return false
},
})
}

View file

@ -0,0 +1,97 @@
"use client"
import { $fetch } from "@lib/api"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import type { ImportProvider } from "@/components/settings/sync-utils"
import type { SyncRun } from "@/hooks/use-sync-runs"
type Connection = z.infer<typeof ConnectionResponseSchema>
export function useTriggerSync() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
provider,
containerTags,
}: {
// connectionId isn't sent to the backend (import is keyed by provider) — kept for cache updates
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
},
// Optimistically flip to "syncing" so the badge/button update instantly; the 5s poll then converges on real state
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ["connections"] })
const previousConnections = queryClient.getQueryData<Connection[]>([
"connections",
])
queryClient.setQueryData<Connection[]>(["connections"], (old) =>
old?.map((c) =>
c.provider === variables.provider
? {
...c,
metadata: {
...((c.metadata as Record<string, unknown> | null) ?? {}),
syncInProgress: true,
},
}
: c,
),
)
const syncRunsKey = ["sync-runs", variables.connectionId]
const previousSyncRuns = queryClient.getQueryData<SyncRun[]>(syncRunsKey)
if (previousSyncRuns) {
const optimisticRun: SyncRun = {
id: `optimistic-${Date.now()}`,
connectionId: variables.connectionId,
status: "running",
triggerType: "manual",
startedAt: new Date().toISOString(),
completedAt: null,
itemsProcessed: 0,
itemsFailed: 0,
error: null,
}
queryClient.setQueryData<SyncRun[]>(syncRunsKey, [
optimisticRun,
...previousSyncRuns,
])
}
return { previousConnections, previousSyncRuns, syncRunsKey }
},
// Don't invalidate connections/sync-runs here — an immediate refetch races the backend and clobbers the optimistic state; the 5s polls handle it
onSuccess: () => {
toast.success("Sync started")
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
},
onError: (error, _variables, context) => {
if (context?.previousConnections !== undefined) {
queryClient.setQueryData(["connections"], context.previousConnections)
}
if (context?.previousSyncRuns !== undefined) {
queryClient.setQueryData(context.syncRunsKey, context.previousSyncRuns)
}
toast.error("Failed to start sync", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
}

View file

@ -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.unknown(),
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() }),