diff --git a/packages/dashboard/src/app/api/deploy/route.ts b/packages/dashboard/src/app/api/deploy/route.ts index 0b089005..3bfdd642 100644 --- a/packages/dashboard/src/app/api/deploy/route.ts +++ b/packages/dashboard/src/app/api/deploy/route.ts @@ -21,6 +21,7 @@ export async function POST(request: NextRequest) { supabaseAccessToken, supabaseProjectRef, stripeKey, + workerIntervalSeconds: 30, }) // Create session to store credentials server-side (for Management API queries) diff --git a/packages/dashboard/src/app/api/sync-progress/route.ts b/packages/dashboard/src/app/api/sync-progress/route.ts new file mode 100644 index 00000000..035a91fb --- /dev/null +++ b/packages/dashboard/src/app/api/sync-progress/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/sessions' + +const SYNC_PROGRESS_QUERY = ` + SELECT json_build_object( + 'run', ( + SELECT row_to_json(r) FROM ( + SELECT account_id, started_at, closed_at, triggered_by, status, + total_processed, total_objects, complete_count, error_count, + running_count, pending_count, error_message + FROM stripe.sync_runs ORDER BY started_at DESC LIMIT 1 + ) r + ), + 'objects', COALESCE(( + SELECT json_agg(row_to_json(p) ORDER BY p.object) + FROM stripe.sync_obj_progress p + ), '[]'::json) + ) AS result +` + +export async function GET(request: NextRequest) { + try { + const sessionId = request.nextUrl.searchParams.get('sessionId') + + if (!sessionId) { + return NextResponse.json({ error: 'Missing sessionId' }, { status: 400 }) + } + + const session = getSession(sessionId) + if (!session) { + return NextResponse.json({ error: 'Session expired' }, { status: 401 }) + } + + const response = await fetch( + `https://api.supabase.com/v1/projects/${session.projectRef}/database/query`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: SYNC_PROGRESS_QUERY }), + } + ) + + if (!response.ok) { + const text = await response.text() + return NextResponse.json({ error: `Database query failed: ${text}` }, { status: 500 }) + } + + const rows = await response.json() + const data = rows?.[0]?.result ?? { run: null, objects: [] } + + return NextResponse.json(data) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/packages/dashboard/src/app/page.tsx b/packages/dashboard/src/app/page.tsx index c2ccd035..ffc8d4fb 100644 --- a/packages/dashboard/src/app/page.tsx +++ b/packages/dashboard/src/app/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { DeployForm } from '@/components/DeployForm' import { SyncStatus } from '@/components/SyncStatus' +import { SyncProgress } from '@/components/SyncProgress' import { InstallationStatus } from '@/components/InstallationStatus' export default function Home() { @@ -15,16 +16,18 @@ export default function Home() {

Stripe Sync

Deploy Stripe sync to your Supabase project

- { - setDeploying(false) - setSessionId(id) - setInstallationComplete(false) - }} - /> + {!sessionId && !deploying && ( + { + setDeploying(false) + setSessionId(id) + setInstallationComplete(false) + }} + /> + )} -
+
{deploying && (
🚀 @@ -44,8 +47,10 @@ export default function Home() { {!deploying && sessionId && installationComplete && (
-

Sync Status

+
+ +
)}
diff --git a/packages/dashboard/src/components/SyncProgress.tsx b/packages/dashboard/src/components/SyncProgress.tsx new file mode 100644 index 00000000..a39a1013 --- /dev/null +++ b/packages/dashboard/src/components/SyncProgress.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' + +interface ObjectProgress { + object: string + pct_complete: number + processed: number +} + +interface SyncRun { + account_id: string + started_at: string + closed_at: string | null + status: string + total_processed: number + total_objects: number + complete_count: number + error_count: number + running_count: number + pending_count: number +} + +interface SyncProgressProps { + sessionId: string +} + +function barColor(pct: number): string { + if (pct >= 100) return '#16a34a' + if (pct > 0) return '#2563eb' + return '#9ca3af' +} + +export function SyncProgress({ sessionId }: SyncProgressProps) { + const [run, setRun] = useState(null) + const [objects, setObjects] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + const fetchData = useCallback(async () => { + try { + const res = await fetch(`/api/sync-progress?sessionId=${sessionId}`) + const data = await res.json() + if (!res.ok) { + setError(data.error) + return + } + setRun(data.run) + setObjects(data.objects ?? []) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch') + } finally { + setLoading(false) + } + }, [sessionId]) + + useEffect(() => { + fetchData() + const interval = setInterval(fetchData, 2000) + return () => clearInterval(interval) + }, [fetchData]) + + if (loading) return
Loading...
+ + if (error) { + return ( +
+ ⚠️ {error} +
+ ) + } + + if (!run || objects.length === 0) return null + + const totalRows = objects.reduce((sum, o) => sum + Number(o.processed), 0) + + return ( +
+ + + + + + + + + + {objects.map((obj) => { + const pct = Number(obj.pct_complete) + return ( + + + + + + ) + })} + +
TableProgressRows
+ {obj.object} + +
+
+
+
+ + {pct.toFixed(1)}% + +
+
+ {Number(obj.processed).toLocaleString()} +
+
+ {totalRows.toLocaleString()} total rows +
+
+ ) +} + +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '16px 20px', + background: '#f9f9f9', + borderRadius: 8, + fontSize: 16, +} + +const tableWrapStyle: React.CSSProperties = { + border: '1px solid #ddd', + borderRadius: 8, + overflow: 'hidden', +} + +const thStyle: React.CSSProperties = { + padding: '10px 16px', + fontSize: 12, + fontWeight: 500, + textTransform: 'uppercase', + letterSpacing: 0.5, + color: '#888', + background: '#fafafa', + borderBottom: '1px solid #ddd', +} + +const tdStyle: React.CSSProperties = { + padding: '10px 16px', + verticalAlign: 'middle', +} + +const trackStyle: React.CSSProperties = { + flex: 1, + height: 6, + background: '#eee', + borderRadius: 3, + overflow: 'hidden', +} + +const footerStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + padding: '10px 16px', + background: '#fafafa', + borderTop: '1px solid #ddd', + fontSize: 13, +}