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;
+ }
+ },
+});