diff --git a/frontend/.env.example b/frontend/.env.example index ea1747a6..dc147102 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,3 +5,5 @@ NEXT_PUBLIC_BACKEND_URL="" NEXT_PUBLIC_URL="" NEXT_PUBLIC_GOOGLE_CLIENT_ID="" OPENAI_API_KEY="" + +EDGE_CONFIG="https://edge-config.vercel.com/REDACTED?token=REDACTED" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8eefe14..3a6aac5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -48,6 +48,7 @@ "@uiw/react-codemirror": "^4.23.10", "@usebasejump/shared": "^0.0.3", "@vercel/analytics": "^1.5.0", + "@vercel/edge-config": "^1.4.0", "@vercel/speed-insights": "^1.2.0", "@xyflow/react": "^12.7.0", "autoprefixer": "10.4.17", @@ -89,6 +90,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.3", "swr": "^2.2.5", "tailwind-merge": "^3.0.2", @@ -8153,6 +8155,32 @@ } } }, + "node_modules/@vercel/edge-config": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-1.4.0.tgz", + "integrity": "sha512-69Wg5gw9DzwnyUmnjToSeLRm1nm8mCPgN0kflX8EHRHyqvzH80wPem5A8rI2LXPb2Y9tJNoqN3vXPcQhS2Wh5g==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/edge-config-fs": "0.1.0" + }, + "engines": { + "node": ">=14.6" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@vercel/edge-config-fs": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vercel/edge-config-fs/-/edge-config-fs-0.1.0.tgz", + "integrity": "sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==", + "license": "Apache-2.0" + }, "node_modules/@vercel/speed-insights": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.2.0.tgz", @@ -16182,6 +16210,12 @@ "randombytes": "^2.1.0" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 54ad167c..7684e0bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,6 +51,7 @@ "@uiw/react-codemirror": "^4.23.10", "@usebasejump/shared": "^0.0.3", "@vercel/analytics": "^1.5.0", + "@vercel/edge-config": "^1.4.0", "@vercel/speed-insights": "^1.2.0", "@xyflow/react": "^12.7.0", "autoprefixer": "10.4.17", @@ -92,6 +93,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.3", "swr": "^2.2.5", "tailwind-merge": "^3.0.2", diff --git a/frontend/src/app/(dashboard)/_components/layout-content.tsx b/frontend/src/app/(dashboard)/_components/layout-content.tsx new file mode 100644 index 00000000..ea872a36 --- /dev/null +++ b/frontend/src/app/(dashboard)/_components/layout-content.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { SidebarLeft } from '@/components/sidebar/sidebar-left'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +// import { PricingAlert } from "@/components/billing/pricing-alert" +import { MaintenanceAlert } from '@/components/maintenance-alert'; +import { useAccounts } from '@/hooks/use-accounts'; +import { useAuth } from '@/components/AuthProvider'; +import { useRouter } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { checkApiHealth } from '@/lib/api'; +import { MaintenancePage } from '@/components/maintenance/maintenance-page'; +import { DeleteOperationProvider } from '@/contexts/DeleteOperationContext'; +import { StatusOverlay } from '@/components/ui/status-overlay'; +import { VSentry } from '@/components/sentry'; +import type { IMaintenanceNotice } from '@/lib/edge-flags'; +import { MaintenanceNotice } from './maintenance-notice'; +import { MaintenanceBanner } from './maintenance-banner'; + +interface DashboardLayoutContentProps { + children: React.ReactNode; + maintenanceNotice: IMaintenanceNotice; +} + +export default function DashboardLayoutContent({ + children, + maintenanceNotice, +}: DashboardLayoutContentProps) { + // const [showPricingAlert, setShowPricingAlert] = useState(false) + const [showMaintenanceAlert, setShowMaintenanceAlert] = useState(false); + const [isApiHealthy, setIsApiHealthy] = useState(true); + const [isCheckingHealth, setIsCheckingHealth] = useState(true); + const { data: accounts } = useAccounts(); + const personalAccount = accounts?.find((account) => account.personal_account); + const { user, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + // setShowPricingAlert(false) + setShowMaintenanceAlert(false); + }, []); + + // Check API health + useEffect(() => { + const checkHealth = async () => { + try { + const health = await checkApiHealth(); + setIsApiHealthy(health.status === 'ok'); + } catch (error) { + console.error('API health check failed:', error); + setIsApiHealthy(false); + } finally { + setIsCheckingHealth(false); + } + }; + + checkHealth(); + // Check health every 30 seconds + const interval = setInterval(checkHealth, 30000); + return () => clearInterval(interval); + }, []); + + // Check authentication status + useEffect(() => { + if (!isLoading && !user) { + router.push('/auth'); + } + }, [user, isLoading, router]); + + if (maintenanceNotice.enabled) { + const now = new Date(); + const startTime = maintenanceNotice.startTime; + const endTime = maintenanceNotice.endTime; + + if (now > startTime) { + return ( +
+
+ +
+
+ ); + } + } + + let mantenanceBanner: React.ReactNode | null = null; + if (maintenanceNotice.enabled) { + mantenanceBanner = ( + + ); + } + + // Show loading state while checking auth or health + if (isLoading || isCheckingHealth) { + return ( +
+ +
+ ); + } + + // Don't render anything if not authenticated + if (!user) { + return null; + } + + // Show maintenance page if API is not healthy + if (!isApiHealthy) { + return ; + } + + return ( + + + + + {mantenanceBanner} +
{children}
+
+ + {/* */} + + + + + {/* Status overlay for deletion operations */} + +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/_components/maintenance-banner.tsx b/frontend/src/app/(dashboard)/_components/maintenance-banner.tsx new file mode 100644 index 00000000..a70f5db2 --- /dev/null +++ b/frontend/src/app/(dashboard)/_components/maintenance-banner.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { AlertCircle, X, Info } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useEffect, useState } from 'react'; + +interface MaintenanceBannerProps { + startTime: string; // ISO string + endTime: string; // ISO string +} + +export function MaintenanceBanner({ + startTime, + endTime, +}: MaintenanceBannerProps) { + const [timeDisplay, setTimeDisplay] = useState(''); + const [isMaintenanceActive, setIsMaintenanceActive] = useState(false); + const [isDismissed, setIsDismissed] = useState(false); + const [isMounted, setIsMounted] = useState(false); + + // Create a unique key for this maintenance window + const maintenanceKey = `maintenance-dismissed-${startTime}-${endTime}`; + + useEffect(() => { + setIsMounted(true); + // Check if this maintenance has been dismissed + const dismissed = localStorage.getItem(maintenanceKey); + if (dismissed === 'true') { + setIsDismissed(true); + } + }, [maintenanceKey]); + + useEffect(() => { + const updateCountdown = () => { + const now = new Date(); + const start = new Date(startTime); + const end = new Date(endTime); + + // Check if maintenance is currently active + if (now >= start && now <= end) { + setIsMaintenanceActive(true); + const diffToEnd = end.getTime() - now.getTime(); + + if (diffToEnd <= 0) { + setTimeDisplay('Maintenance completed'); + return; + } + + const hours = Math.floor(diffToEnd / (1000 * 60 * 60)); + const minutes = Math.floor( + (diffToEnd % (1000 * 60 * 60)) / (1000 * 60), + ); + + if (hours > 0) { + setTimeDisplay(`${hours}h ${minutes}m remaining`); + } else { + setTimeDisplay(`${minutes}m remaining`); + } + } else if (now < start) { + // Maintenance hasn't started yet + setIsMaintenanceActive(false); + const diffToStart = start.getTime() - now.getTime(); + + if (diffToStart <= 0) { + setTimeDisplay('starting now'); + return; + } + + const days = Math.floor(diffToStart / (1000 * 60 * 60 * 24)); + const hours = Math.floor( + (diffToStart % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60), + ); + const minutes = Math.floor( + (diffToStart % (1000 * 60 * 60)) / (1000 * 60), + ); + + if (days > 0) { + setTimeDisplay(`starting in ${days}d ${hours}h`); + } else if (hours > 0) { + setTimeDisplay(`starting in ${hours}h ${minutes}m`); + } else { + setTimeDisplay(`starting in ${minutes}m`); + } + } else { + // Maintenance is over + setTimeDisplay(''); + } + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 60000); // Update every minute + + return () => clearInterval(interval); + }, [startTime, endTime]); + + const handleDismiss = () => { + setIsDismissed(true); + localStorage.setItem(maintenanceKey, 'true'); + }; + + const formatDateTime = (isoString: string) => { + const date = new Date(isoString); + return date.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }); + }; + + const getDuration = () => { + const start = new Date(startTime); + const end = new Date(endTime); + const diff = end.getTime() - start.getTime(); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } + }; + + // Don't show banner if maintenance is over or dismissed + const now = new Date(); + const end = new Date(endTime); + + // Don't render until mounted (SSR safety) + if (!isMounted) { + return null; + } + + if (now > end || isDismissed) { + return null; + } + + return ( + + + + + {isMaintenanceActive + ? 'Scheduled maintenance in progress' + : 'Scheduled maintenance'} + + {timeDisplay && ( + <> + + {timeDisplay} + + )} + + {/* Info icon with tooltip */} + + + + + +
+ {!isMaintenanceActive && ( +
Starts: {formatDateTime(startTime)}
+ )} +
+ {isMaintenanceActive ? 'Expected completion' : 'Ends'}:{' '} + {formatDateTime(endTime)} +
+
Duration: {getDuration()}
+
+
+
+
+ + {/* Dismiss button */} + +
+ ); +} diff --git a/frontend/src/app/(dashboard)/_components/maintenance-notice.tsx b/frontend/src/app/(dashboard)/_components/maintenance-notice.tsx new file mode 100644 index 00000000..640422a7 --- /dev/null +++ b/frontend/src/app/(dashboard)/_components/maintenance-notice.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { Clock, AlertTriangle, Server } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface MaintenanceNoticeProps { + endTime: string; // ISO string +} + +export function MaintenanceNotice({ endTime }: MaintenanceNoticeProps) { + const [timeRemaining, setTimeRemaining] = useState(''); + + useEffect(() => { + const updateTimeRemaining = () => { + const now = new Date(); + const end = new Date(endTime); + const diff = end.getTime() - now.getTime(); + + if (diff <= 0) { + setTimeRemaining('Almost done!'); + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + setTimeRemaining(`${hours}h ${minutes}m remaining`); + } else { + setTimeRemaining(`${minutes}m remaining`); + } + }; + + updateTimeRemaining(); + const interval = setInterval(updateTimeRemaining, 60000); // Update every minute + + return () => clearInterval(interval); + }, [endTime]); + + const formatEndTime = (isoString: string) => { + const date = new Date(isoString); + return date.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }); + }; + + return ( +
+
+
+
+
+ +
+
+ +
+
+

+ Scheduled Maintenance +

+

+ We're performing scheduled maintenance to improve our systems. + Some features may be temporarily unavailable. +

+
+ +
+
+ + Expected completion: {formatEndTime(endTime)} +
+ + {timeRemaining && ( +
+ + + {timeRemaining} + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 2b111d24..c2bfc96c 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -1,110 +1,24 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { SidebarLeft } from '@/components/sidebar/sidebar-left'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -// import { PricingAlert } from "@/components/billing/pricing-alert" -import { MaintenanceAlert } from '@/components/maintenance-alert'; -import { useAccounts } from '@/hooks/use-accounts'; -import { useAuth } from '@/components/AuthProvider'; -import { useRouter } from 'next/navigation'; -import { Loader2 } from 'lucide-react'; -import { checkApiHealth } from '@/lib/api'; -import { MaintenancePage } from '@/components/maintenance/maintenance-page'; -import { DeleteOperationProvider } from '@/contexts/DeleteOperationContext'; -import { StatusOverlay } from '@/components/ui/status-overlay'; -import { VSentry } from '@/components/sentry'; +import { maintenanceNoticeFlag } from '@/lib/edge-flags'; +import DashboardLayoutContent from './_components/layout-content'; +import { cookies } from 'next/headers'; interface DashboardLayoutProps { children: React.ReactNode; } -export default function DashboardLayout({ children }: DashboardLayoutProps) { - // const [showPricingAlert, setShowPricingAlert] = useState(false) - const [showMaintenanceAlert, setShowMaintenanceAlert] = useState(false); - const [isApiHealthy, setIsApiHealthy] = useState(true); - const [isCheckingHealth, setIsCheckingHealth] = useState(true); - const { data: accounts } = useAccounts(); - const personalAccount = accounts?.find((account) => account.personal_account); - const { user, isLoading } = useAuth(); - const router = useRouter(); - - useEffect(() => { - // setShowPricingAlert(false) - setShowMaintenanceAlert(false); - }, []); - - // Check API health - useEffect(() => { - const checkHealth = async () => { - try { - const health = await checkApiHealth(); - setIsApiHealthy(health.status === 'ok'); - } catch (error) { - console.error('API health check failed:', error); - setIsApiHealthy(false); - } finally { - setIsCheckingHealth(false); - } - }; - - checkHealth(); - // Check health every 30 seconds - const interval = setInterval(checkHealth, 30000); - return () => clearInterval(interval); - }, []); - - // Check authentication status - useEffect(() => { - if (!isLoading && !user) { - router.push('/auth'); - } - }, [user, isLoading, router]); - - // Show loading state while checking auth or health - if (isLoading || isCheckingHealth) { - return ( -
- -
- ); - } - - // Don't render anything if not authenticated - if (!user) { - return null; - } - - // Show maintenance page if API is not healthy - if (!isApiHealthy) { - return ; - } +const getMaintenanceNotice = async () => { + const maintenanceNotice = await maintenanceNoticeFlag(); + return maintenanceNotice; +}; +export default async function DashboardLayout({ + children, +}: DashboardLayoutProps) { + await cookies(); + const maintenanceNotice = await getMaintenanceNotice(); return ( - - - - -
{children}
-
- - {/* */} - - - - - {/* Status overlay for deletion operations */} - -
-
+ + {children} + ); } diff --git a/frontend/src/lib/edge-flags.ts b/frontend/src/lib/edge-flags.ts new file mode 100644 index 00000000..a7e2d338 --- /dev/null +++ b/frontend/src/lib/edge-flags.ts @@ -0,0 +1,56 @@ +import { flag } from 'flags/next'; +import { getAll } from '@vercel/edge-config'; + +export type IMaintenanceNotice = + | { + enabled: true; + startTime: Date; + endTime: Date; + } + | { + enabled: false; + startTime?: undefined; + endTime?: undefined; + }; + +export const maintenanceNoticeFlag = flag({ + key: 'maintenance-notice', + async decide() { + try { + if (!process.env.EDGE_CONFIG) { + console.warn('Edge config is not set'); + return { enabled: false } as const; + } + + const flags = await getAll([ + 'maintenance-notice_start-time', + 'maintenance-notice_end-time', + 'maintenance-notice_enabled', + ]); + + if (!flags['maintenance-notice_enabled']) { + return { enabled: false } as const; + } + + const startTime = new Date(flags['maintenance-notice_start-time']); + const endTime = new Date(flags['maintenance-notice_end-time']); + + if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) { + throw new Error( + `Invalid maintenance notice start or end time: ${flags['maintenance-notice_start-time']} or ${flags['maintenance-notice_end-time']}`, + ); + } + + return { + enabled: true, + startTime, + endTime, + } as const; + } catch (cause) { + console.error( + new Error('Failed to get maintenance notice flag', { cause }), + ); + return { enabled: false } as const; + } + }, +});