From 03c8312f5fbbf021344d154bd64f70c628a1e2b0 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 29 Jul 2025 17:35:10 -0400 Subject: [PATCH] Add maintenance banner feature (#9981) Co-authored-by: openhands Co-authored-by: Graham Neubig Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> --- .../maintenance/maintenance-banner.test.tsx | 52 ++++++++++++++ frontend/src/api/open-hands.types.ts | 3 + .../maintenance/maintenance-banner.tsx | 69 +++++++++++++++++++ frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 16 +++++ frontend/src/mocks/handlers.ts | 4 ++ frontend/src/routes/root-layout.tsx | 4 ++ 7 files changed, 149 insertions(+) create mode 100644 frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx create mode 100644 frontend/src/components/features/maintenance/maintenance-banner.tsx diff --git a/frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx b/frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx new file mode 100644 index 0000000000..66dce76909 --- /dev/null +++ b/frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; + +// Mock react-i18next +vi.mock("react-i18next", async () => { + const actual = await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: { time?: string }) => { + const translations: Record = { + "MAINTENANCE$SCHEDULED_MESSAGE": `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`, + }; + return translations[key] || key; + }, + }), + }; +}); + +describe("MaintenanceBanner", () => { + it("renders maintenance banner with formatted time", () => { + const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp + + const { container } = render(); + + // Check if the banner is rendered + expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument(); + + // Check if the warning icon (SVG) is present + const svgIcon = container.querySelector('svg'); + expect(svgIcon).toBeInTheDocument(); + }); + + it("handles invalid date gracefully", () => { + const invalidTime = "invalid-date"; + + render(); + + // Should still render the banner with the original string + expect(screen.getByText(/Scheduled maintenance will begin at invalid-date/)).toBeInTheDocument(); + }); + + it("formats ISO date string correctly", () => { + const isoTime = "2024-01-15T15:30:00.000Z"; + + render(); + + // Should render the banner (exact time format will depend on user's timezone) + expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 8171a7e8ab..f03e00931b 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -56,6 +56,9 @@ export interface GetConfigResponse { HIDE_LLM_SETTINGS: boolean; HIDE_MICROAGENT_MANAGEMENT?: boolean; }; + MAINTENANCE?: { + startTime: string; + }; } export interface GetVSCodeUrlResponse { diff --git a/frontend/src/components/features/maintenance/maintenance-banner.tsx b/frontend/src/components/features/maintenance/maintenance-banner.tsx new file mode 100644 index 0000000000..78579c9ee3 --- /dev/null +++ b/frontend/src/components/features/maintenance/maintenance-banner.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import { FaTriangleExclamation } from "react-icons/fa6"; + +interface MaintenanceBannerProps { + startTime: string; +} + +export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) { + const { t } = useTranslation(); + // Convert EST timestamp to user's local timezone + const formatMaintenanceTime = (estTimeString: string): string => { + try { + // Parse the EST timestamp + // If the string doesn't include timezone info, assume it's EST + let dateToFormat: Date; + + if ( + estTimeString.includes("T") && + (estTimeString.includes("-05:00") || + estTimeString.includes("-04:00") || + estTimeString.includes("EST") || + estTimeString.includes("EDT")) + ) { + // Already has timezone info + dateToFormat = new Date(estTimeString); + } else { + // Assume EST and convert to UTC for proper parsing + // EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity + const estDate = new Date(estTimeString); + if (Number.isNaN(estDate.getTime())) { + throw new Error("Invalid date"); + } + dateToFormat = estDate; + } + + // Format to user's local timezone + return dateToFormat.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); + } catch (error) { + // Fallback to original string if parsing fails + // eslint-disable-next-line no-console + console.warn("Failed to parse maintenance time:", error); + return estTimeString; + } + }; + + const localTime = formatMaintenanceTime(startTime); + + return ( +
+
+
+ +
+
+

+ {t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })} +

+
+
+
+ ); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index b2413b9ac4..8894dec247 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1,5 +1,6 @@ // this file generate by script, don't modify it manually!!! export enum I18nKey { + MAINTENANCE$SCHEDULED_MESSAGE = "MAINTENANCE$SCHEDULED_MESSAGE", MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND", MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT", MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 62fbe7a070..4967258471 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -1,4 +1,20 @@ { + "MAINTENANCE$SCHEDULED_MESSAGE": { + "en": "Scheduled maintenance will begin at {{time}}", + "ja": "予定されたメンテナンスは{{time}}に開始されます", + "zh-CN": "计划维护将于{{time}}开始", + "zh-TW": "計劃維護將於{{time}}開始", + "ko-KR": "예정된 유지보수가 {{time}}에 시작됩니다", + "no": "Planlagt vedlikehold starter {{time}}", + "it": "La manutenzione programmata inizierà alle {{time}}", + "pt": "A manutenção programada começará às {{time}}", + "es": "El mantenimiento programado comenzará a las {{time}}", + "ar": "ستبدأ الصيانة المجدولة في {{time}}", + "fr": "La maintenance programmée commencera à {{time}}", + "tr": "Planlı bakım {{time}} tarihinde başlayacak", + "de": "Die geplante Wartung beginnt um {{time}}", + "uk": "Планове технічне обслуговування розпочнеться о {{time}}" + }, "MICROAGENT$NO_REPOSITORY_FOUND": { "en": "No repository found to launch microagent", "ja": "マイクロエージェントを起動するためのリポジトリが見つかりません", diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index cd9dfeb2c2..8cb3614e1e 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -187,6 +187,10 @@ export const handlers = [ ENABLE_BILLING: false, HIDE_LLM_SETTINGS: mockSaas, }, + // Uncomment the following to test the maintenance banner + // MAINTENANCE: { + // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp + // }, }; return HttpResponse.json(config); diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 2871daf1fa..960bdbd6ce 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -26,6 +26,7 @@ import { useAutoLogin } from "#/hooks/use-auto-login"; import { useAuthCallback } from "#/hooks/use-auth-callback"; import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; +import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; export function ErrorBoundary() { const error = useRouteError(); @@ -205,6 +206,9 @@ export default function MainApp() { id="root-outlet" className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto" > + {config.data?.MAINTENANCE && ( + + )}