mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 16:13:19 +00:00
refactor: address review feedback for sync visibility
- Restore 60s baseline polling (was regressed to false) - Extract getConnectionMeta helper to eliminate 5 inline casts - Remove unnecessary containerTags intersection casts - Deduplicate provider title maps into shared PROVIDER_DISPLAY_NAMES - Fix formatRelativeTime: handle future dates, add minutes tier - Preserve auth/expired state in SyncStatusBadge (red Disconnected) - Disable sync button for expired connections - Fix mobile width overflow on sync history sheet - Add smart polling to useSyncRuns when a run is running - Add clarifying comment on connectionId in useTriggerSync - Replace z.any() with z.unknown() for import endpoint output - Remove unnecessary 'use client' from sync-status-badge
This commit is contained in:
parent
eeb728e000
commit
1c1901f711
7 changed files with 102 additions and 56 deletions
|
|
@ -28,6 +28,22 @@ 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",
|
||||
|
|
@ -156,6 +172,8 @@ function ConnectionRow({
|
|||
if (!config) return null
|
||||
|
||||
const Icon = config.icon
|
||||
const meta = getConnectionMeta(connection)
|
||||
const expired = isConnectionExpired(connection)
|
||||
|
||||
const getProjectDisplayName = (containerTag: string): string => {
|
||||
if (containerTag === DEFAULT_PROJECT_ID) return "Default Project"
|
||||
|
|
@ -164,13 +182,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 (
|
||||
|
|
@ -195,14 +211,9 @@ function ConnectionRow({
|
|||
{config.title}
|
||||
</span>
|
||||
<SyncStatusBadge
|
||||
syncInProgress={
|
||||
(connection.metadata as Record<string, unknown>)
|
||||
?.syncInProgress as boolean | undefined
|
||||
}
|
||||
lastSyncedAt={
|
||||
(connection.metadata as Record<string, unknown>)
|
||||
?.lastSyncedAt as number | undefined
|
||||
}
|
||||
syncInProgress={meta.syncInProgress}
|
||||
lastSyncedAt={meta.lastSyncedAt}
|
||||
isExpired={expired}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -221,10 +232,22 @@ function ConnectionRow({
|
|||
e.stopPropagation()
|
||||
onTriggerSync()
|
||||
}}
|
||||
disabled={isSyncing || disabled}
|
||||
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={isSyncing ? "Sync in progress" : "Sync now"}
|
||||
title={isSyncing ? "Sync in progress" : "Sync now"}
|
||||
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" />
|
||||
|
|
@ -279,12 +302,7 @@ function ConnectionRow({
|
|||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Last synced:{" "}
|
||||
{formatRelativeTime(
|
||||
(connection.metadata as Record<string, unknown>)?.lastSyncedAt as
|
||||
| number
|
||||
| undefined,
|
||||
)}
|
||||
Last synced: {formatRelativeTime(meta.lastSyncedAt)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
|
|
@ -293,7 +311,7 @@ function ConnectionRow({
|
|||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{documentCount} {config.documentLabel} connected
|
||||
{meta.documentCount} {config.documentLabel} connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -399,14 +417,10 @@ export default function ConnectionsMCP() {
|
|||
staleTime: 30 * 1000,
|
||||
refetchInterval: (query) => {
|
||||
const conns = query.state.data as Connection[] | undefined
|
||||
if (
|
||||
conns?.some(
|
||||
(c) => (c.metadata as Record<string, unknown>)?.syncInProgress,
|
||||
)
|
||||
) {
|
||||
if (conns?.some((c) => getConnectionMeta(c).syncInProgress)) {
|
||||
return 5000
|
||||
}
|
||||
return false
|
||||
return 60 * 1000
|
||||
},
|
||||
enabled: hasProProduct,
|
||||
})
|
||||
|
|
@ -532,19 +546,14 @@ export default function ConnectionsMCP() {
|
|||
triggerSync.mutate({
|
||||
connectionId: connection.id,
|
||||
provider: connection.provider as ImportProvider,
|
||||
containerTags: (
|
||||
connection as Connection & {
|
||||
containerTags?: string[]
|
||||
}
|
||||
).containerTags,
|
||||
containerTags: connection.containerTags,
|
||||
})
|
||||
}
|
||||
isSyncing={
|
||||
(triggerSync.isPending &&
|
||||
triggerSync.variables?.connectionId ===
|
||||
connection.id) ||
|
||||
!!(connection.metadata as Record<string, unknown>)
|
||||
?.syncInProgress
|
||||
getConnectionMeta(connection).syncInProgress
|
||||
}
|
||||
onViewHistory={() =>
|
||||
setSyncHistorySheet({ open: true, connection })
|
||||
|
|
@ -635,7 +644,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) {
|
||||
|
|
|
|||
|
|
@ -14,22 +14,13 @@ import type { SyncRun } from "@/hooks/use-sync-runs"
|
|||
import {
|
||||
formatRelativeTime,
|
||||
TRIGGER_TYPE_LABELS,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
} 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]" },
|
||||
|
|
@ -122,7 +113,7 @@ export function SyncHistorySheet({
|
|||
} = useSyncRuns(open && connection ? connection.id : "")
|
||||
|
||||
const providerTitle = connection
|
||||
? (PROVIDER_TITLES[connection.provider] ?? connection.provider)
|
||||
? (PROVIDER_DISPLAY_NAMES[connection.provider] ?? connection.provider)
|
||||
: ""
|
||||
const email = connection?.email ?? ""
|
||||
|
||||
|
|
@ -130,7 +121,7 @@ export function SyncHistorySheet({
|
|||
<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]"
|
||||
className="bg-[#0D0F14] border-l border-[#1A1D24] w-full 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
|
|
@ -7,7 +5,9 @@ import { formatRelativeTime } from "@/components/settings/sync-utils"
|
|||
function deriveStatus(
|
||||
syncInProgress?: boolean,
|
||||
lastSyncedAt?: number,
|
||||
): "syncing" | "synced" | "idle" {
|
||||
isExpired?: boolean,
|
||||
): "syncing" | "synced" | "expired" | "idle" {
|
||||
if (isExpired) return "expired"
|
||||
if (syncInProgress) return "syncing"
|
||||
if (lastSyncedAt) return "synced"
|
||||
return "idle"
|
||||
|
|
@ -16,15 +16,17 @@ function deriveStatus(
|
|||
interface SyncStatusBadgeProps {
|
||||
syncInProgress?: boolean
|
||||
lastSyncedAt?: number
|
||||
isExpired?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SyncStatusBadge({
|
||||
syncInProgress,
|
||||
lastSyncedAt,
|
||||
isExpired,
|
||||
className,
|
||||
}: SyncStatusBadgeProps) {
|
||||
const status = deriveStatus(syncInProgress, lastSyncedAt)
|
||||
const status = deriveStatus(syncInProgress, lastSyncedAt, isExpired)
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
|
|
@ -33,6 +35,7 @@ export function SyncStatusBadge({
|
|||
"size-[7px] rounded-full",
|
||||
status === "syncing" && "bg-[#4BA0FA] animate-pulse",
|
||||
status === "synced" && "bg-[#00AC3F]",
|
||||
status === "expired" && "bg-[#EF4444]",
|
||||
status === "idle" && "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
|
|
@ -67,6 +70,16 @@ export function SyncStatusBadge({
|
|||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === "expired" && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#EF4444]",
|
||||
)}
|
||||
>
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
{status === "idle" && (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ export function formatRelativeTime(
|
|||
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 (diffHours < 1) return "Just now"
|
||||
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`
|
||||
|
|
@ -27,6 +33,17 @@ export const TRIGGER_TYPE_LABELS: Record<string, string> = {
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
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
|
||||
|
|
@ -31,5 +35,12 @@ export function useSyncRuns(connectionId: string) {
|
|||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export function useTriggerSync() {
|
|||
provider,
|
||||
containerTags,
|
||||
}: {
|
||||
// connectionId is not sent to the backend — the import endpoint is keyed
|
||||
// by provider, so it re-syncs all connections for that provider.
|
||||
// It's kept here so onSuccess can target cache invalidation.
|
||||
connectionId: string
|
||||
provider: ImportProvider
|
||||
containerTags?: string[]
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ export const apiSchema = createSchema({
|
|||
input: z.object({
|
||||
containerTags: z.array(z.string()).optional(),
|
||||
}),
|
||||
output: z.any(),
|
||||
output: z.unknown(),
|
||||
params: z.object({
|
||||
provider: z.enum([
|
||||
"google-drive",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue