From 6c96c50d996b742b65857f1b550447eec4edd491 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 2 Aug 2026 04:07:22 -0700 Subject: [PATCH] Drive Sync now pending UX + error alerts (#90) (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Closes [#90](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/90) - Depends on backend [chat_backend#57](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/57) (202 + pending status) - Sync now: enqueue, poll until status leaves `pending`, show **error/success alert** - Per-connection `last_sync_error` shown when status is `error` - OAuth green “Drive connected” banner unchanged (connect-only) - Refreshes document list on successful sync ## Test plan - [x] `drive.test` + `DocumentStoragePage.test` - [ ] Manual with backend #57: Sync fails (Drive API off) → red alert with truncated error - [ ] Manual: Sync succeeds → success alert + docs refresh - [ ] Manual: Syncing… / button disabled while pendingReviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/91 --- .../DriveConnectionsSection.tsx | 108 +++++++++++++++++- .../DocumentStoragePage.tsx | 2 + llm-fe/src/llm-fe/utils/drive.test.ts | 19 ++- llm-fe/src/llm-fe/utils/drive.ts | 64 +++++++++-- 4 files changed, 180 insertions(+), 13 deletions(-) diff --git a/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx b/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx index 58fabf5..9942a18 100644 --- a/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx +++ b/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx @@ -8,9 +8,11 @@ import { connectDrive, disconnectDriveConnection, fetchDriveConnections, + formatDriveSyncError, parseResourceIdsInput, saveDriveResourceSelection, syncDriveConnection, + waitForDriveSyncSettlement, } from "../../utils/drive"; const Section = styled.div` @@ -171,6 +173,45 @@ const EmptyState = styled.p` opacity: 0.6; `; +const AlertBanner = styled.div<{ $tone: 'error' | 'success' }>` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin: 0 0 1.25rem 0; + padding: 0.85rem 1rem; + border-radius: 0.5rem; + border: 1px solid + ${({ $tone }) => ($tone === 'error' ? 'rgba(255, 107, 107, 0.45)' : 'rgba(46, 204, 113, 0.45)')}; + background: ${({ $tone }) => + $tone === 'error' ? 'rgba(255, 71, 87, 0.12)' : 'rgba(46, 204, 113, 0.12)'}; + color: ${({ theme }) => theme.colors.text}; + font-size: 0.9rem; + line-height: 1.4; +`; + +const AlertDismiss = styled.button` + background: transparent; + border: none; + color: inherit; + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; + padding: 0; + + &:hover { + opacity: 1; + } +`; + +const SyncErrorText = styled.p` + margin: 0.75rem 0 0 0; + font-size: 0.85rem; + color: #ff6b6b; + line-height: 1.4; +`; + const PROVIDER_LABELS: Record = { google: "Google Drive", microsoft: "OneDrive", @@ -183,6 +224,7 @@ type DriveConnectionsSectionProps = { title: string; description?: string; connectIntent: DriveConnectIntent; + onSynced?: () => void; }; const DriveConnectionsSection = ({ @@ -190,11 +232,15 @@ const DriveConnectionsSection = ({ title, description, connectIntent, + onSynced, }: DriveConnectionsSectionProps): JSX.Element => { const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); const [connectError, setConnectError] = useState(null); + const [syncBanner, setSyncBanner] = useState<{ tone: 'error' | 'success'; message: string } | null>( + null + ); const [resourceInputs, setResourceInputs] = useState>({}); const [pendingAction, setPendingAction] = useState>({}); @@ -219,6 +265,16 @@ const DriveConnectionsSection = ({ setPendingAction((prev) => ({ ...prev, [id]: action })); }; + const upsertConnection = (updated: DriveConnectionType) => { + setConnections((prev) => { + const exists = prev.some((conn) => conn.id === updated.id); + if (!exists) { + return (updated.kind || 'personal') === kind ? [...prev, updated] : prev; + } + return prev.map((conn) => (conn.id === updated.id ? { ...conn, ...updated } : conn)); + }); + }; + const handleDisconnect = async (id: number) => { setAction(id, 'disconnect'); try { @@ -226,6 +282,7 @@ const DriveConnectionsSection = ({ setConnections((prev) => prev.filter((conn) => conn.id !== id)); } catch (err) { console.log(err); + setSyncBanner({ tone: 'error', message: 'Could not disconnect this drive. Try again.' }); } finally { setAction(id, undefined); } @@ -233,11 +290,33 @@ const DriveConnectionsSection = ({ const handleSync = async (id: number) => { setAction(id, 'sync'); + setSyncBanner(null); try { - await syncDriveConnection(id); - await loadConnections(); + const enqueued = await syncDriveConnection(id); + upsertConnection(enqueued.connection); + + const settled = + enqueued.connection.last_sync_status === 'pending' + ? await waitForDriveSyncSettlement(id) + : enqueued.connection; + + upsertConnection(settled); + + if (settled.last_sync_status === 'error') { + setSyncBanner({ + tone: 'error', + message: formatDriveSyncError(settled.last_sync_error), + }); + } else if (settled.last_sync_status === 'ok') { + setSyncBanner({ tone: 'success', message: 'Drive sync finished.' }); + onSynced?.(); + } } catch (err) { - console.log(err); + console.error(err); + const message = + err instanceof Error ? err.message : 'Drive sync failed. Try again.'; + setSyncBanner({ tone: 'error', message }); + await loadConnections(); } finally { setAction(id, undefined); } @@ -258,6 +337,7 @@ const DriveConnectionsSection = ({ setResourceInputs((prev) => ({ ...prev, [id]: '' })); } catch (err) { console.log(err); + setSyncBanner({ tone: 'error', message: 'Could not save folder selection. Try again.' }); } finally { setAction(id, undefined); } @@ -268,6 +348,19 @@ const DriveConnectionsSection = ({ {title} {description && {description}} + {syncBanner && ( + + {syncBanner.message} + setSyncBanner(null)} + > + × + + + )} + @@ -328,9 +422,9 @@ const DriveConnectionsSection = ({ handleSync(conn.id)} - disabled={Boolean(action)} + disabled={Boolean(action) || conn.last_sync_status === 'pending'} > - {action === 'sync' ? 'Syncing…' : 'Sync now'} + {isSyncing ? 'Syncing…' : 'Sync now'} + {conn.last_sync_status === 'error' && conn.last_sync_error && ( + {formatDriveSyncError(conn.last_sync_error)} + )} + {selectedLabels.length > 0 && ( {selectedLabels.map((label, idx) => ( diff --git a/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx b/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx index efad40b..2d01060 100644 --- a/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx +++ b/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx @@ -434,6 +434,7 @@ const DocumentStoragePageInner = (): JSX.Element => { title="Cloud drives" description="Connect your personal Google Drive or OneDrive so its files can be searched in chat." connectIntent="link_drive" + onSynced={fetchDocuments} /> {account?.is_company_manager && ( @@ -442,6 +443,7 @@ const DocumentStoragePageInner = (): JSX.Element => { title="Company knowledge sources" description="Connect a shared Google Shared Drive or Microsoft 365 site so your whole team can search these files in chat." connectIntent="link_company_drive" + onSynced={fetchDocuments} /> )} diff --git a/llm-fe/src/llm-fe/utils/drive.test.ts b/llm-fe/src/llm-fe/utils/drive.test.ts index ecb3f2d..f463b37 100644 --- a/llm-fe/src/llm-fe/utils/drive.test.ts +++ b/llm-fe/src/llm-fe/utils/drive.test.ts @@ -1,4 +1,8 @@ -import { driveConnectUrl, parseResourceIdsInput } from './drive'; +import { + driveConnectUrl, + formatDriveSyncError, + parseResourceIdsInput, +} from './drive'; describe('parseResourceIdsInput', () => { it('splits comma and newline separated ids and trims whitespace', () => { @@ -10,6 +14,19 @@ describe('parseResourceIdsInput', () => { }); }); +describe('formatDriveSyncError', () => { + it('returns a fallback when empty', () => { + expect(formatDriveSyncError('')).toMatch(/Drive sync failed/); + }); + + it('truncates long provider error payloads', () => { + const long = 'x'.repeat(400); + const formatted = formatDriveSyncError(long, 50); + expect(formatted.length).toBeLessThanOrEqual(51); + expect(formatted.endsWith('…')).toBe(true); + }); +}); + describe('driveConnectUrl', () => { const originalEnv = process.env.REACT_APP_BACKEND_REST_API_BASE_URL; diff --git a/llm-fe/src/llm-fe/utils/drive.ts b/llm-fe/src/llm-fe/utils/drive.ts index 0d88c62..d6b2da4 100644 --- a/llm-fe/src/llm-fe/utils/drive.ts +++ b/llm-fe/src/llm-fe/utils/drive.ts @@ -8,6 +8,8 @@ export type DriveConnectionKind = 'personal' | 'company'; /** intent query param sent to /auth/oauth/:provider/start/ for drive-linking flows (#83/#84). */ export type DriveConnectIntent = 'link_drive' | 'link_company_drive'; +export type DriveSyncStatus = 'ok' | 'error' | 'pending' | 'never'; + export type DriveConnectionType = { id: number; provider: DriveProvider; @@ -17,11 +19,16 @@ export type DriveConnectionType = { selected_resource_ids?: string[]; selected_resource_labels?: string[]; last_sync_at?: string | null; - last_sync_status?: string | null; + last_sync_status?: DriveSyncStatus | string | null; last_sync_error?: string; created?: string; }; +export type DriveSyncEnqueueResponse = { + queued?: boolean; + connection: DriveConnectionType; +}; + export async function fetchDriveConnections(): Promise { const { data } = await axiosInstance.get('/drive/connections/'); return Array.isArray(data) ? data : []; @@ -47,12 +54,55 @@ export async function saveDriveResourceSelection( return data; } -export async function syncDriveConnection(connectionId: number): Promise { - const { data } = await axiosInstance.post<{ - connection?: DriveConnectionType; - } & DriveConnectionType>(`/drive/connections/${connectionId}/sync/`); - if (!data) return null; - return data.connection || data; +/** Enqueue a Drive sync (#57/#90). Returns quickly with pending status. */ +export async function syncDriveConnection( + connectionId: number +): Promise { + const { data } = await axiosInstance.post( + `/drive/connections/${connectionId}/sync/` + ); + if (!data?.connection) { + throw new Error('Drive sync did not return a connection.'); + } + return data; +} + +const DEFAULT_SYNC_POLL_MS = 1500; +const DEFAULT_SYNC_TIMEOUT_MS = 120_000; + +/** Poll connections until the target leaves ``pending`` (or timeout). */ +export async function waitForDriveSyncSettlement( + connectionId: number, + options?: { intervalMs?: number; timeoutMs?: number } +): Promise { + const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS; + const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const connections = await fetchDriveConnections(); + const connection = connections.find((item) => item.id === connectionId); + if (!connection) { + throw new Error('Drive connection disappeared while syncing.'); + } + if (connection.last_sync_status !== 'pending') { + return connection; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error('Drive sync timed out. Check status and try again.'); +} + +/** Shorten long provider JSON error blobs for toasts. */ +export function formatDriveSyncError(raw?: string | null, maxLen = 280): string { + const text = (raw || '').trim(); + if (!text) { + return 'Drive sync failed. Try again or check provider API settings.'; + } + if (text.length <= maxLen) { + return text; + } + return `${text.slice(0, maxLen).trim()}…`; } /** Absolute backend OAuth start URL for a drive-link flow (personal or company). */