feat(frontend): implement maintenance notice and banner components with edge configuration integration

This commit is contained in:
user 2025-06-24 16:36:23 +00:00
parent c0fbd794de
commit be00efbd70
No known key found for this signature in database
8 changed files with 562 additions and 101 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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",

View File

@ -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 (
<div className="w-screen h-screen flex items-center justify-center">
<div className="max-w-xl">
<MaintenanceNotice endTime={endTime.toISOString()} />
</div>
</div>
);
}
}
let mantenanceBanner: React.ReactNode | null = null;
if (maintenanceNotice.enabled) {
mantenanceBanner = (
<MaintenanceBanner
startTime={maintenanceNotice.startTime.toISOString()}
endTime={maintenanceNotice.endTime.toISOString()}
/>
);
}
// Show loading state while checking auth or health
if (isLoading || isCheckingHealth) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
// Don't render anything if not authenticated
if (!user) {
return null;
}
// Show maintenance page if API is not healthy
if (!isApiHealthy) {
return <MaintenancePage />;
}
return (
<DeleteOperationProvider>
<SidebarProvider>
<SidebarLeft />
<SidebarInset>
{mantenanceBanner}
<div className="bg-background">{children}</div>
</SidebarInset>
{/* <PricingAlert
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={false}
accountId={personalAccount?.account_id}
/> */}
<MaintenanceAlert
open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert}
closeable={true}
/>
<VSentry />
{/* Status overlay for deletion operations */}
<StatusOverlay />
</SidebarProvider>
</DeleteOperationProvider>
);
}

View File

@ -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<string>('');
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 (
<Alert
className={`py-2 ${
isMaintenanceActive
? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950'
: 'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950'
}`}
>
<AlertCircle
className={`h-4 w-4 ${
isMaintenanceActive
? 'text-orange-600 dark:text-orange-400'
: 'text-yellow-600 dark:text-yellow-400'
}`}
/>
<AlertDescription
className={`flex items-center gap-2 ${
isMaintenanceActive
? 'text-orange-700 dark:text-orange-300'
: 'text-yellow-700 dark:text-yellow-300'
}`}
>
<span className="font-medium">
{isMaintenanceActive
? 'Scheduled maintenance in progress'
: 'Scheduled maintenance'}
</span>
{timeDisplay && (
<>
<span></span>
<span>{timeDisplay}</span>
</>
)}
{/* Info icon with tooltip */}
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 cursor-help opacity-60 hover:opacity-100 transition-opacity" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1 text-xs">
{!isMaintenanceActive && (
<div>Starts: {formatDateTime(startTime)}</div>
)}
<div>
{isMaintenanceActive ? 'Expected completion' : 'Ends'}:{' '}
{formatDateTime(endTime)}
</div>
<div>Duration: {getDuration()}</div>
</div>
</TooltipContent>
</Tooltip>
</AlertDescription>
{/* Dismiss button */}
<Button
variant="ghost"
size="sm"
className={`absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 p-0 hover:bg-transparent ${
isMaintenanceActive
? 'text-orange-600 hover:text-orange-800 dark:text-orange-400 dark:hover:text-orange-200'
: 'text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-200'
}`}
onClick={handleDismiss}
aria-label="Dismiss maintenance notice"
>
<X className="h-3 w-3" />
</Button>
</Alert>
);
}

View File

@ -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<string>('');
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 (
<div className="w-full max-w-4xl mx-auto p-4">
<div className="bg-background border border-border rounded-lg p-6 shadow-sm">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/20">
<Server className="h-6 w-6 text-amber-600 dark:text-amber-500 animate-pulse" />
</div>
</div>
<div className="flex-1 space-y-4">
<div>
<h3 className="text-lg font-semibold text-foreground">
Scheduled Maintenance
</h3>
<p className="text-muted-foreground mt-1">
We're performing scheduled maintenance to improve our systems.
Some features may be temporarily unavailable.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4 flex-shrink-0" />
<span>Expected completion: {formatEndTime(endTime)}</span>
</div>
{timeRemaining && (
<div className="inline-flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/30 rounded-md">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{timeRemaining}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
// Don't render anything if not authenticated
if (!user) {
return null;
}
// Show maintenance page if API is not healthy
if (!isApiHealthy) {
return <MaintenancePage />;
}
const getMaintenanceNotice = async () => {
const maintenanceNotice = await maintenanceNoticeFlag();
return maintenanceNotice;
};
export default async function DashboardLayout({
children,
}: DashboardLayoutProps) {
await cookies();
const maintenanceNotice = await getMaintenanceNotice();
return (
<DeleteOperationProvider>
<SidebarProvider>
<SidebarLeft />
<SidebarInset>
<div className="bg-background">{children}</div>
</SidebarInset>
{/* <PricingAlert
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={false}
accountId={personalAccount?.account_id}
/> */}
<MaintenanceAlert
open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert}
closeable={true}
/>
<VSentry />
{/* Status overlay for deletion operations */}
<StatusOverlay />
</SidebarProvider>
</DeleteOperationProvider>
<DashboardLayoutContent maintenanceNotice={maintenanceNotice}>
{children}
</DashboardLayoutContent>
);
}

View File

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