mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
5a0dffbc4c
commit
68a7781de7
8 changed files with 879 additions and 120 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
267
apps/web/components/settings/sync-history-panel.tsx
Normal file
267
apps/web/components/settings/sync-history-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
apps/web/components/settings/sync-status-badge.tsx
Normal file
95
apps/web/components/settings/sync-status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
apps/web/components/settings/sync-utils.ts
Normal file
55
apps/web/components/settings/sync-utils.ts
Normal 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"
|
||||
46
apps/web/hooks/use-sync-runs.ts
Normal file
46
apps/web/hooks/use-sync-runs.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
97
apps/web/hooks/use-trigger-sync.ts
Normal file
97
apps/web/hooks/use-trigger-sync.ts
Normal 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",
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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() }),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue