mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 16:13:19 +00:00
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
This commit is contained in:
parent
6437814f25
commit
eeb728e000
7 changed files with 552 additions and 54 deletions
|
|
@ -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<typeof ConnectionResponseSchema>
|
||||
|
||||
|
|
@ -128,64 +133,29 @@ 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,
|
||||
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}
|
||||
</span>
|
||||
<ConnectionStatusBadge connected={isConnected} />
|
||||
<SyncStatusBadge
|
||||
syncInProgress={
|
||||
(connection.metadata as Record<string, unknown>)
|
||||
?.syncInProgress as boolean | undefined
|
||||
}
|
||||
lastSyncedAt={
|
||||
(connection.metadata as Record<string, unknown>)
|
||||
?.lastSyncedAt as number | undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -235,15 +214,48 @@ 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}
|
||||
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={isSyncing ? "Sync in progress" : "Sync now"}
|
||||
title={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()
|
||||
onViewHistory()
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="text-[#737373] hover:text-[#FAFAFA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5"
|
||||
aria-label="Sync history"
|
||||
title="Sync history"
|
||||
>
|
||||
<History className="size-[18px]" />
|
||||
</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 +279,12 @@ function ConnectionRow({
|
|||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Added: {formatRelativeTime(connection.createdAt)}
|
||||
Last synced:{" "}
|
||||
{formatRelativeTime(
|
||||
(connection.metadata as Record<string, unknown>)?.lastSyncedAt as
|
||||
| number
|
||||
| undefined,
|
||||
)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
|
|
@ -342,6 +359,11 @@ export default function ConnectionsMCP() {
|
|||
open: boolean
|
||||
connection: Connection | null
|
||||
}>({ open: false, connection: null })
|
||||
const triggerSync = useTriggerSync()
|
||||
const [syncHistorySheet, setSyncHistorySheet] = useState<{
|
||||
open: boolean
|
||||
connection: Connection | null
|
||||
}>({ open: false, connection: null })
|
||||
|
||||
const projects = (queryClient.getQueryData<Project[]>(["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<string, unknown>)?.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<string, unknown>)
|
||||
?.syncInProgress
|
||||
}
|
||||
onViewHistory={() =>
|
||||
setSyncHistorySheet({ open: true, connection })
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
@ -594,6 +647,14 @@ export default function ConnectionsMCP() {
|
|||
}}
|
||||
isDeleting={deleteConnectionMutation.isPending}
|
||||
/>
|
||||
|
||||
<SyncHistorySheet
|
||||
open={syncHistorySheet.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSyncHistorySheet({ open: false, connection: null })
|
||||
}}
|
||||
connection={syncHistorySheet.connection}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
201
apps/web/components/settings/sync-history-sheet.tsx
Normal file
201
apps/web/components/settings/sync-history-sheet.tsx
Normal file
|
|
@ -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<typeof ConnectionResponseSchema>
|
||||
|
||||
const PROVIDER_TITLES: Record<string, string> = {
|
||||
"google-drive": "Google Drive",
|
||||
notion: "Notion",
|
||||
onedrive: "OneDrive",
|
||||
gmail: "Gmail",
|
||||
github: "GitHub",
|
||||
"web-crawler": "Web Crawler",
|
||||
s3: "S3",
|
||||
}
|
||||
|
||||
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 SyncRunCard({ run }: { run: SyncRun }) {
|
||||
const colors = STATUS_COLORS[run.status] ?? {
|
||||
dot: "bg-[#4BA0FA] animate-pulse",
|
||||
text: "text-[#4BA0FA]",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#14161A] rounded-[12px] p-4 border border-[rgba(82,89,102,0.2)] flex flex-col gap-2">
|
||||
{/* Top row: status + trigger + time */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("size-[7px] rounded-full", colors.dot)} />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px]",
|
||||
colors.text,
|
||||
)}
|
||||
>
|
||||
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] bg-[#1A1D24] rounded-full px-2 py-0.5",
|
||||
)}
|
||||
>
|
||||
{TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
{formatRelativeTime(run.startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Middle row: item counts */}
|
||||
{(run.itemsProcessed > 0 || run.itemsFailed > 0) && (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-1 text-[13px]",
|
||||
)}
|
||||
>
|
||||
<span className="text-[#737373]">{run.itemsProcessed} processed</span>
|
||||
{run.itemsFailed > 0 && (
|
||||
<span className="text-[#EF4444]">· {run.itemsFailed} failed</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom row: error message */}
|
||||
{run.error && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] text-[#EF4444]/80 break-words line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{run.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="bg-[#0D0F14] border-l border-[#1A1D24] w-[400px] sm:max-w-[400px] gap-0 overflow-hidden p-0 [&>button]:text-[#737373] [&>button]:hover:text-[#FAFAFA]"
|
||||
>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b border-[#1A1D24]">
|
||||
<SheetTitle
|
||||
className={cn(dmSans125ClassName(), "text-[#FAFAFA] text-[18px]")}
|
||||
>
|
||||
Sync History
|
||||
</SheetTitle>
|
||||
<SheetDescription
|
||||
className={cn(dmSans125ClassName(), "text-[#737373] text-[14px]")}
|
||||
>
|
||||
{providerTitle}
|
||||
{email ? ` · ${email}` : ""}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1 flex flex-col gap-3">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="size-6 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Failed to load sync history
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] 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-12">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
No sync runs yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
syncRuns &&
|
||||
syncRuns.length > 0 &&
|
||||
syncRuns.map((run) => <SyncRunCard key={run.id} run={run} />)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
82
apps/web/components/settings/sync-status-badge.tsx
Normal file
82
apps/web/components/settings/sync-status-badge.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"size-[7px] rounded-full",
|
||||
status === "syncing" && "bg-[#4BA0FA] animate-pulse",
|
||||
status === "synced" && "bg-[#00AC3F]",
|
||||
status === "idle" && "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
{status === "syncing" && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
Syncing...
|
||||
</span>
|
||||
)}
|
||||
{status === "synced" && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#00AC3F]",
|
||||
)}
|
||||
>
|
||||
Synced
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{formatRelativeTime(lastSyncedAt)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === "idle" && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Waiting for first sync
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
apps/web/components/settings/sync-utils.ts
Normal file
38
apps/web/components/settings/sync-utils.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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"
|
||||
35
apps/web/hooks/use-sync-runs.ts
Normal file
35
apps/web/hooks/use-sync-runs.ts
Normal file
|
|
@ -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<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",
|
||||
})
|
||||
}
|
||||
46
apps/web/hooks/use-trigger-sync.ts
Normal file
46
apps/web/hooks/use-trigger-sync.ts
Normal file
|
|
@ -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",
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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() }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue