From e1c4ba98168ba663b27fafa415cac372165f2c01 Mon Sep 17 00:00:00 2001 From: Kirill Chernakov Date: Fri, 3 Jan 2025 14:43:34 +0400 Subject: [PATCH 1/2] fix: handle unhealthy backend on frontend --- keep-ui/app/(keep)/error.css | 25 ---- keep-ui/app/(keep)/error.ts | 5 + keep-ui/app/(keep)/error.tsx | 79 ----------- keep-ui/app/(keep)/loading.tsx | 2 +- .../(keep)/settings/auth/users-settings.tsx | 12 +- .../app/(keep)/workflows/workflows.client.tsx | 22 +-- keep-ui/app/global-error.tsx | 14 +- keep-ui/components/navbar/UserInfo.tsx | 45 +++--- .../incident-list/ui/incident-list-error.tsx | 29 +--- .../incident-list/ui/incident-list.tsx | 12 +- keep-ui/shared/api/ApiClient.ts | 2 +- keep-ui/shared/api/KeepApiError.ts | 17 ++- keep-ui/shared/lib/hooks/useHealth.ts | 46 +++++++ .../ui/ErrorComponent/ErrorComponent.tsx | 81 +++++++++++ keep-ui/shared/ui/ErrorComponent/index.ts | 1 + .../shared/ui/KeepLogoError/KeepLogoError.tsx | 129 ++++++++++++++++++ keep-ui/shared/ui/KeepLogoError/index.ts | 1 + keep-ui/shared/ui/KeepLogoError/keep_big.svg | 47 +++++++ .../shared/ui/KeepLogoError/logo-error.css | 57 ++++++++ keep-ui/shared/ui/index.ts | 1 + keep-ui/shared/ui/utils/showErrorToast.tsx | 5 +- 21 files changed, 437 insertions(+), 195 deletions(-) delete mode 100644 keep-ui/app/(keep)/error.css create mode 100644 keep-ui/app/(keep)/error.ts delete mode 100644 keep-ui/app/(keep)/error.tsx create mode 100644 keep-ui/shared/lib/hooks/useHealth.ts create mode 100644 keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx create mode 100644 keep-ui/shared/ui/ErrorComponent/index.ts create mode 100644 keep-ui/shared/ui/KeepLogoError/KeepLogoError.tsx create mode 100644 keep-ui/shared/ui/KeepLogoError/index.ts create mode 100644 keep-ui/shared/ui/KeepLogoError/keep_big.svg create mode 100644 keep-ui/shared/ui/KeepLogoError/logo-error.css diff --git a/keep-ui/app/(keep)/error.css b/keep-ui/app/(keep)/error.css deleted file mode 100644 index 8ea2ff391..000000000 --- a/keep-ui/app/(keep)/error.css +++ /dev/null @@ -1,25 +0,0 @@ -.error-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; -} - -.error-message { - font-size: 18px; - margin-bottom: 16px; -} - -.error-url { - font-size: 14px; - margin-bottom: 8px; - color: gray; -} - -.error-image { - margin-top: 16px; - width: 150px; - height: 150px; -} diff --git a/keep-ui/app/(keep)/error.ts b/keep-ui/app/(keep)/error.ts new file mode 100644 index 000000000..dc011a6e4 --- /dev/null +++ b/keep-ui/app/(keep)/error.ts @@ -0,0 +1,5 @@ +"use client"; + +import { ErrorComponent } from "@/shared/ui"; + +export default ErrorComponent; diff --git a/keep-ui/app/(keep)/error.tsx b/keep-ui/app/(keep)/error.tsx deleted file mode 100644 index 1de50c459..000000000 --- a/keep-ui/app/(keep)/error.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// The error.js file convention allows you to gracefully handle unexpected runtime errors. -// The way it does this is by automatically wrap a route segment and its nested children in a React Error Boundary. -// https://nextjs.org/docs/app/api-reference/file-conventions/error -// https://nextjs.org/docs/app/building-your-application/routing/error-handling#how-errorjs-works - -"use client"; -import Image from "next/image"; -import "./error.css"; -import { useEffect } from "react"; -import { Title, Subtitle } from "@tremor/react"; -import { Button, Text } from "@tremor/react"; -import { KeepApiError } from "@/shared/api"; -import * as Sentry from "@sentry/nextjs"; -import { useSignOut } from "@/shared/lib/hooks/useSignOut"; - -export default function ErrorComponent({ - error, - reset, -}: { - error: Error | KeepApiError; - reset: () => void; -}) { - const signOut = useSignOut(); - - useEffect(() => { - Sentry.captureException(error); - }, [error]); - - return ( -
- - {error instanceof KeepApiError - ? "An error occurred while fetching data from the backend" - : error.message || "An error occurred"} - -
- - {error instanceof KeepApiError && ( -
- Status Code: {error.statusCode} -
- Message: {error.message} -
- URL: {error.url} -
- )} -
-
- {error instanceof KeepApiError && error.proposedResolution && ( - {error.proposedResolution} - )} - -
- Keep -
- {error instanceof KeepApiError && error.statusCode === 401 ? ( - - ) : ( - - )} -
- ); -} diff --git a/keep-ui/app/(keep)/loading.tsx b/keep-ui/app/(keep)/loading.tsx index c56c5d8ac..d05bf61fd 100644 --- a/keep-ui/app/(keep)/loading.tsx +++ b/keep-ui/app/(keep)/loading.tsx @@ -17,7 +17,7 @@ export default function Loading({ }`} > loading; + if (error) { + return ; + } + + if ((!users || !roles || !groups) && !isLoading) { + return ; + } const handleRowClick = (user: User) => { setSelectedUser(user); diff --git a/keep-ui/app/(keep)/workflows/workflows.client.tsx b/keep-ui/app/(keep)/workflows/workflows.client.tsx index 1e4b2d0d1..0f3d494c0 100644 --- a/keep-ui/app/(keep)/workflows/workflows.client.tsx +++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx @@ -2,17 +2,16 @@ import { useRef, useState } from "react"; import useSWR from "swr"; -import { Callout, Subtitle } from "@tremor/react"; +import { Subtitle } from "@tremor/react"; import { ArrowUpOnSquareStackIcon, - ExclamationCircleIcon, PlusCircleIcon, } from "@heroicons/react/24/outline"; import { Workflow, MockWorkflow } from "./models"; import Loading from "@/app/(keep)/loading"; import React from "react"; import WorkflowsEmptyState from "./noworkflows"; -import WorkflowTile, { WorkflowTileOld } from "./workflow-tile"; +import WorkflowTile from "./workflow-tile"; import { Button, Card, Title } from "@tremor/react"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; @@ -20,7 +19,7 @@ import Modal from "@/components/ui/Modal"; import MockWorkflowCardSection from "./mockworkflows"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; -import { showErrorToast, Input } from "@/shared/ui"; +import { showErrorToast, Input, ErrorComponent } from "@/shared/ui"; export default function WorkflowsPage() { const api = useApi(); @@ -62,19 +61,12 @@ export default function WorkflowsPage() { (url: string) => api.get(url) ); - if (isLoading || !data) return ; + if (isLoading || (!data && !error)) { + return ; + } if (error) { - return ( - - Failed to load workflows - - ); + return {}} />; } const onDrop = async (files: any) => { diff --git a/keep-ui/app/global-error.tsx b/keep-ui/app/global-error.tsx index 9388e06e0..1aee749cc 100644 --- a/keep-ui/app/global-error.tsx +++ b/keep-ui/app/global-error.tsx @@ -1,26 +1,16 @@ "use client"; -import * as Sentry from "@sentry/nextjs"; -import NextError from "next/error"; -import { useEffect } from "react"; +import { ErrorComponent } from "@/shared/ui"; export default function GlobalError({ error, }: { error: Error & { digest?: string }; }) { - useEffect(() => { - Sentry.captureException(error); - }, [error]); - return ( - {/* `NextError` is the default Next.js error page component. Its type - definition requires a `statusCode` prop. However, since the App Router - does not expose status codes for errors, we simply pass 0 to render a - generic error message. */} - + ); diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx index ca919e9e7..c8cee5528 100644 --- a/keep-ui/components/navbar/UserInfo.tsx +++ b/keep-ui/components/navbar/UserInfo.tsx @@ -6,10 +6,9 @@ import { Session } from "next-auth"; import { useConfig } from "utils/hooks/useConfig"; import { AuthType } from "@/utils/authenticationType"; import Link from "next/link"; -import { AiOutlineRight } from "react-icons/ai"; import { VscDebugDisconnect } from "react-icons/vsc"; import { useFloating } from "@floating-ui/react"; -import { Icon, Subtitle } from "@tremor/react"; +import { Subtitle } from "@tremor/react"; import UserAvatar from "./UserAvatar"; import * as Frigade from "@frigade/react"; import { useState } from "react"; @@ -27,9 +26,6 @@ type UserDropdownProps = { }; const UserDropdown = ({ session }: UserDropdownProps) => { - const { userRole, user } = session; - const { name, image, email } = user; - const { data: configData } = useConfig(); const signOut = useSignOut(); const { refs, floatingStyles } = useFloating({ @@ -37,6 +33,13 @@ const UserDropdown = ({ session }: UserDropdownProps) => { strategy: "fixed", }); + if (!session || !session.user) { + return null; + } + + const { userRole, user } = session; + const { name, image, email } = user; + const isNoAuth = configData?.AUTH_TYPE === AuthType.NOAUTH; return ( @@ -96,21 +99,23 @@ export const UserInfo = ({ session }: UserInfoProps) => { return ( <>
    - {isMounted && !config?.FRIGADE_DISABLED && flow?.isCompleted === false && ( -
  • - setIsOnboardingOpen(true)} - /> - setIsOnboardingOpen(false)} - variables={{ - name: session?.user.name ?? session?.user.email, - }} - /> -
  • - )} + {isMounted && + !config?.FRIGADE_DISABLED && + flow?.isCompleted === false && ( +
  • + setIsOnboardingOpen(true)} + /> + setIsOnboardingOpen(false)} + variables={{ + name: session?.user?.name ?? session?.user?.email, + }} + /> +
  • + )}
  • Providers diff --git a/keep-ui/features/incident-list/ui/incident-list-error.tsx b/keep-ui/features/incident-list/ui/incident-list-error.tsx index b8bb6f455..93bddc90a 100644 --- a/keep-ui/features/incident-list/ui/incident-list-error.tsx +++ b/keep-ui/features/incident-list/ui/incident-list-error.tsx @@ -1,7 +1,6 @@ -import { Fragment } from "react"; -import { Button, Subtitle, Title } from "@tremor/react"; +"use client"; import NotAuthorized from "@/app/not-authorized"; - +import { ErrorComponent } from "@/shared/ui"; interface IncidentListErrorProps { incidentError: any; } @@ -13,27 +12,5 @@ export const IncidentListError = ({ return ; } - return ( - -
    -
    - Failed to load incidents - - Error: {incidentError.message} - - - {incidentError.proposedResolution || - "Please try again. If the issue persists, contact us"} - - -
    -
    -
    - ); + return ; }; diff --git a/keep-ui/features/incident-list/ui/incident-list.tsx b/keep-ui/features/incident-list/ui/incident-list.tsx index 6fcb1f73d..5e0029afc 100644 --- a/keep-ui/features/incident-list/ui/incident-list.tsx +++ b/keep-ui/features/incident-list/ui/incident-list.tsx @@ -106,20 +106,12 @@ export function IncidentList({ function renderIncidents() { if (incidentsError) { - return ( - - - - ); + return ; } if (isLoading) { // TODO: only show this on the initial load - return ( - - - - ); + return ; } if (incidents && (incidents.items.length > 0 || areFiltersApplied)) { diff --git a/keep-ui/shared/api/ApiClient.ts b/keep-ui/shared/api/ApiClient.ts index c878f5794..76be12887 100644 --- a/keep-ui/shared/api/ApiClient.ts +++ b/keep-ui/shared/api/ApiClient.ts @@ -83,7 +83,7 @@ export class ApiClient { ); } } - throw new Error("An error occurred while fetching the data."); + throw new Error("An error occurred while fetching the data"); } if (response.headers.get("content-length") === "0") { diff --git a/keep-ui/shared/api/KeepApiError.ts b/keep-ui/shared/api/KeepApiError.ts index 2d9dad937..20796b937 100644 --- a/keep-ui/shared/api/KeepApiError.ts +++ b/keep-ui/shared/api/KeepApiError.ts @@ -1,5 +1,4 @@ -// Custom Error Class - +// Custom Error export class KeepApiError extends Error { url: string; proposedResolution: string; @@ -37,3 +36,17 @@ export class KeepApiReadOnlyError extends KeepApiError { this.name = "KeepReadOnlyError"; } } + +export class KeepApiHealthError extends KeepApiError { + constructor(message: string = "API server is not available") { + const proposedResolution = + "Check if the Keep backend is running and API_URL is correct."; + super(message, "", proposedResolution, {}, 503); + this.name = "KeepApiHealthError"; + this.message = message; + } + + toString() { + return `${this.name}: ${this.message} - ${this.proposedResolution}`; + } +} diff --git a/keep-ui/shared/lib/hooks/useHealth.ts b/keep-ui/shared/lib/hooks/useHealth.ts new file mode 100644 index 000000000..bd5d183d5 --- /dev/null +++ b/keep-ui/shared/lib/hooks/useHealth.ts @@ -0,0 +1,46 @@ +import { useApi } from "@/shared/lib/hooks/useApi"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +type UseHealthResult = { + isHealthy: boolean; + lastChecked: number; + checkHealth: () => Promise; +}; + +const CACHE_DURATION = 30000; + +export function useHealth(): UseHealthResult { + const api = useApi(); + const [isHealthy, setIsHealthy] = useState(true); + const [lastChecked, setLastChecked] = useState(0); + + const checkHealth = useCallback(async () => { + // Skip if checked recently + if (Date.now() - lastChecked < CACHE_DURATION) { + return; + } + + try { + await api.request("/healthcheck", { + method: "GET", + // Short timeout to avoid blocking + signal: AbortSignal.timeout(2000), + }); + setIsHealthy(true); + } catch (error) { + setIsHealthy(false); + } + setLastChecked(Date.now()); + }, [api]); + + useEffect(() => { + if (!lastChecked) { + checkHealth(); + } + }, [checkHealth, lastChecked]); + + return useMemo( + () => ({ isHealthy, lastChecked, checkHealth }), + [isHealthy, lastChecked, checkHealth] + ); +} diff --git a/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx b/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx new file mode 100644 index 000000000..92d619d64 --- /dev/null +++ b/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx @@ -0,0 +1,81 @@ +// The error.js file convention allows you to gracefully handle unexpected runtime errors. +// The way it does this is by automatically wrap a route segment and its nested children in a React Error Boundary. +// https://nextjs.org/docs/app/api-reference/file-conventions/error +// https://nextjs.org/docs/app/building-your-application/routing/error-handling#how-errorjs-works + +"use client"; +import { useEffect } from "react"; +import { Title, Subtitle } from "@tremor/react"; +import { Button, Text } from "@tremor/react"; +import { KeepApiError } from "@/shared/api"; +import * as Sentry from "@sentry/nextjs"; +import { useSignOut } from "@/shared/lib/hooks/useSignOut"; +import { KeepApiHealthError } from "@/shared/api/KeepApiError"; +import { useHealth } from "@/shared/lib/hooks/useHealth"; +import { KeepLogoError } from "@/shared/ui/KeepLogoError"; + +export function ErrorComponent({ + error: originalError, + reset, +}: { + error: Error | KeepApiError; + reset?: () => void; +}) { + const signOut = useSignOut(); + const { isHealthy } = useHealth(); + + useEffect(() => { + Sentry.captureException(originalError); + }, [originalError]); + + const error = isHealthy ? originalError : new KeepApiHealthError(); + + return ( +
    + +
    + {error.message || "An error occurred"} + {error instanceof KeepApiError && error.proposedResolution && ( + {error.proposedResolution} + )} +
    + + {error instanceof KeepApiError && ( + <> + {error.statusCode &&

    Status Code: {error.statusCode}

    } + {error.message &&

    Message: {error.message}

    } + {error.url &&

    URL: {error.url}

    } + + )} +
    +
    + {error instanceof KeepApiError && error.statusCode === 401 ? ( + + ) : ( + + )}{" "} + +
    +
    + ); +} diff --git a/keep-ui/shared/ui/ErrorComponent/index.ts b/keep-ui/shared/ui/ErrorComponent/index.ts new file mode 100644 index 000000000..7939a78b6 --- /dev/null +++ b/keep-ui/shared/ui/ErrorComponent/index.ts @@ -0,0 +1 @@ +export { ErrorComponent } from "./ErrorComponent"; diff --git a/keep-ui/shared/ui/KeepLogoError/KeepLogoError.tsx b/keep-ui/shared/ui/KeepLogoError/KeepLogoError.tsx new file mode 100644 index 000000000..0329c92e6 --- /dev/null +++ b/keep-ui/shared/ui/KeepLogoError/KeepLogoError.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import Image from "next/image"; +import "./logo-error.css"; + +export interface KeepLogoErrorProps { + width?: number; + height?: number; +} + +export const KeepLogoError = ({ + width = 200, + height = 200, +}: KeepLogoErrorProps) => { + return ( +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Keep Logo +
    +
    + +
    + Keep Logo +
    +
    + +
    + Keep Logo +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/keep-ui/shared/ui/KeepLogoError/index.ts b/keep-ui/shared/ui/KeepLogoError/index.ts new file mode 100644 index 000000000..a99f7f054 --- /dev/null +++ b/keep-ui/shared/ui/KeepLogoError/index.ts @@ -0,0 +1 @@ +export { KeepLogoError } from "./KeepLogoError"; diff --git a/keep-ui/shared/ui/KeepLogoError/keep_big.svg b/keep-ui/shared/ui/KeepLogoError/keep_big.svg new file mode 100644 index 000000000..a416df31c --- /dev/null +++ b/keep-ui/shared/ui/KeepLogoError/keep_big.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/keep-ui/shared/ui/KeepLogoError/logo-error.css b/keep-ui/shared/ui/KeepLogoError/logo-error.css new file mode 100644 index 000000000..d89dc4fab --- /dev/null +++ b/keep-ui/shared/ui/KeepLogoError/logo-error.css @@ -0,0 +1,57 @@ +.wrapper { + position: relative; + width: 16rem; + height: 16rem; +} + +.logo-container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + filter: url(#tvNoise); + animation: + tvShift 0.1s infinite, + majorShift 4s infinite; +} + +@keyframes tvShift { + 0% { + transform: translate(0, 0); + } + 25% { + transform: translate(1px, -1px); + } + 50% { + transform: translate(-1px, 1px); + } + 75% { + transform: translate(1px, 1px); + } + 100% { + transform: translate(0, 0); + } +} + +@keyframes majorShift { + 0%, + 95% { + transform: translate(0, 0); + } + 95.2% { + transform: translate(15px, -8px) skew(-12deg) scale(1.1); + } + 95.7% { + transform: translate(-10px, -10px) skew(15deg) scale(0.95); + } + 96.2% { + transform: translate(8px, 12px) skew(-5deg) scale(1.05); + } + 96.7% { + transform: translate(-12px, 5px) skew(8deg) scale(0.9); + } + 97.2% { + transform: translate(0, 0); + } +} diff --git a/keep-ui/shared/ui/index.ts b/keep-ui/shared/ui/index.ts index 2abae10ae..d1817f136 100644 --- a/keep-ui/shared/ui/index.ts +++ b/keep-ui/shared/ui/index.ts @@ -13,6 +13,7 @@ export { SeverityBorderIcon } from "./SeverityBorderIcon"; export { TableSeverityCell } from "./TableSeverityCell"; export { Select } from "./Select"; export { VerticalRoundedList } from "./VerticalRoundedList"; +export { ErrorComponent } from "./ErrorComponent"; export { getCommonPinningStylesAndClassNames } from "./utils/table-utils"; export { ThemeScript, WatchUpdateTheme, ThemeControl } from "./theme"; diff --git a/keep-ui/shared/ui/utils/showErrorToast.tsx b/keep-ui/shared/ui/utils/showErrorToast.tsx index 32fb0c273..2f487a485 100644 --- a/keep-ui/shared/ui/utils/showErrorToast.tsx +++ b/keep-ui/shared/ui/utils/showErrorToast.tsx @@ -23,7 +23,10 @@ export function showErrorToast( options ); } else if (error instanceof KeepApiError) { - toast.error(customMessage || error.message, options); + toast.error( + customMessage || `${error.message}. ${error.proposedResolution}`, + options + ); } else { toast.error( customMessage || From 429c0102dba261403cc7feecab1dc0f9a63e955f Mon Sep 17 00:00:00 2001 From: Kirill Chernakov Date: Tue, 7 Jan 2025 11:57:09 +0400 Subject: [PATCH 2/2] fix: workflows.client.tsx --- keep-ui/app/(keep)/workflows/workflows.client.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/keep-ui/app/(keep)/workflows/workflows.client.tsx b/keep-ui/app/(keep)/workflows/workflows.client.tsx index 0f3d494c0..7a1849794 100644 --- a/keep-ui/app/(keep)/workflows/workflows.client.tsx +++ b/keep-ui/app/(keep)/workflows/workflows.client.tsx @@ -61,14 +61,14 @@ export default function WorkflowsPage() { (url: string) => api.get(url) ); - if (isLoading || (!data && !error)) { - return ; - } - if (error) { return {}} />; } + if (isLoading || !data) { + return ; + } + const onDrop = async (files: any) => { const fileUpload = async ( formData: FormData,