From e4efc656c1d3bcfab1ef56b3d6410cbe69829f94 Mon Sep 17 00:00:00 2001 From: nblenke Date: Thu, 14 Mar 2024 17:18:17 -0400 Subject: [PATCH 1/2] feat: create status page [42] --- __tests__/pages/status.test.tsx | 98 +++++++++++++++++++++++++ components/footer.tsx | 15 ++++ lib/ghostcloud.ts | 6 +- pages/status.tsx | 125 ++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 __tests__/pages/status.test.tsx create mode 100644 pages/status.tsx diff --git a/__tests__/pages/status.test.tsx b/__tests__/pages/status.test.tsx new file mode 100644 index 0000000..79f1b06 --- /dev/null +++ b/__tests__/pages/status.test.tsx @@ -0,0 +1,98 @@ +// @ts-nocheck +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import Status from "../../pages/status" +import useWeb3AuthStore from "../../store/web3-auth" +import useOpenLoginSession from "../../hooks/useOpenLoginSession" +import { useFetchMetas } from "../../lib/ghostcloud" +import { useRouter } from "next/router" + +jest.mock("@chakra-ui/react", () => ({ + ...jest.requireActual("@chakra-ui/react"), + useTheme: () => ({ + colors: { + green: { + 400: "", + }, + red: { + 400: "", + }, + }, + }), +})) +jest.mock("react-query", () => ({ + useQuery: jest.fn(), +})) +jest.mock("../../hooks/useOpenLoginSession", () => jest.fn()) +jest.mock("../../lib/ghostcloud", () => ({ + useFetchMetas: jest.fn(() => ({ + data: { meta: [] }, + isLoading: false, + refetch: jest.fn(), + })), +})) +jest.mock("../../store/web3-auth", () => jest.fn()) +jest.mock("next/router", () => ({ + useRouter: jest.fn().mockReturnValue({ + route: "/status", + pathname: "", + query: {}, + asPath: "", + push: jest.fn(), + }), +})) + +describe("Status", () => { + it("should redirect to homepage if not connected", async () => { + const pushMock = jest.fn() + useRouter.mockReturnValue({ push: pushMock }) + useWeb3AuthStore.mockReturnValueOnce({ + isConnected: jest.fn().mockReturnValue(false), + }) + useFetchMetas.mockReturnValueOnce([ + { data: { meta: [] }, isLoading: false, refetch: jest.fn() }, + ]) + render() + expect(pushMock).toHaveBeenCalledWith("/") + }) + + it("should show spinner if loading", async () => { + useWeb3AuthStore.mockReturnValueOnce({ + isConnected: jest.fn().mockReturnValue(false), + }) + useOpenLoginSession.mockReturnValueOnce(false) + useFetchMetas.mockReturnValueOnce([ + { data: { meta: [] }, isLoading: true, refetch: jest.fn() }, + ]) + render() + expect(screen.getByText("Loading...")).toBeInTheDocument() + }) + + it("should render service status", async () => { + useWeb3AuthStore.mockReturnValueOnce({ + isConnected: jest.fn().mockReturnValue(true), + }) + useOpenLoginSession.mockReturnValueOnce(true) + useFetchMetas.mockReturnValueOnce([ + { data: { meta: [] }, isLoading: false, refetch: jest.fn() }, + ]) + render() + expect(screen.getByText("All Systems Operational")).toBeInTheDocument() + }) + + it("should render deployment status", async () => { + useWeb3AuthStore.mockReturnValueOnce({ + isConnected: jest.fn().mockReturnValue(true), + }) + useOpenLoginSession.mockReturnValueOnce(true) + useFetchMetas.mockReturnValueOnce([ + { + data: { meta: [{}], pagination: { total: 1 } }, + isLoading: false, + refetch: jest.fn(), + }, + ]) + render() + expect(screen.getByText("1 Deployment Active")).toBeInTheDocument() + }) +}) diff --git a/components/footer.tsx b/components/footer.tsx index f0e76e8..619aac8 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -9,11 +9,16 @@ import { useColorModeValue, } from "@chakra-ui/react" import Link from "next/link" +import useWeb3AuthStore from "../store/web3-auth" + import logoDark from "../public/logo-white.png" import logoLight from "../public/logo-black.png" import manifest from "../public/manifest-powered.webp" export default function Footer() { + const store = useWeb3AuthStore() + const isConnected = store.isConnected() + const bgColor = useColorModeValue( "modes.dark.background", "modes.light.background", @@ -42,6 +47,16 @@ export default function Footer() { Home + {isConnected && ( + <> + + Dashboard + + + Status + + + )} Terms Of Service diff --git a/lib/ghostcloud.ts b/lib/ghostcloud.ts index 63e54d0..df9b8b0 100644 --- a/lib/ghostcloud.ts +++ b/lib/ghostcloud.ts @@ -268,7 +268,9 @@ export const useRemoveDeployment = () => { const pageLimit = 10 // Query the Ghostcloud RPC endpoint for deployments created by the current user -export const useFetchMetas = (): [ +export const useFetchMetas = ( + showError: boolean = true, +): [ UseQueryResult, number, number, @@ -315,7 +317,7 @@ export const useFetchMetas = (): [ queryKey: ["metas", page], queryFn: list, onError: error => { - displayError("Failed to fetch deployments", error as Error) + showError && displayError("Failed to fetch deployments", error as Error) }, keepPreviousData: true, }) diff --git a/pages/status.tsx b/pages/status.tsx new file mode 100644 index 0000000..b125ad5 --- /dev/null +++ b/pages/status.tsx @@ -0,0 +1,125 @@ +import { useFetchMetas } from "../lib/ghostcloud" +import { useEffect, useState } from "react" +import useWeb3AuthStore from "../store/web3-auth" +import useOpenLoginSession from "../hooks/useOpenLoginSession" +import { + Box, + Card, + Container, + Flex, + Heading, + Icon, + Spinner, + Text, + useTheme, +} from "@chakra-ui/react" +import { useRouter } from "next/router" +import { IoIosCheckmarkCircle, IoIosWarning } from "react-icons/io" + +interface StatusCardProps { + error: boolean + msg: string +} + +const StatusCard = ({ error, msg }: StatusCardProps) => { + const theme = useTheme() + + return ( + + + + + + + {msg} + + + + ) +} + +const Status = () => { + const store = useWeb3AuthStore() + const isConnected = store.isConnected() + const router = useRouter() + const [{ data: metas, isLoading: isMetaLoading, refetch: refetchMetas }] = + useFetchMetas(false) + const { pagination: { total = 0 } = {} } = metas || {} + const [error, setError] = useState(false) + const [updated, setUpdated] = useState("") + const hasSession = useOpenLoginSession() + + // Redirect if not logged in + useEffect(() => { + if (!isMetaLoading && !isConnected && !hasSession) { + router.push("/") + } + }, [isConnected, isMetaLoading, hasSession, router]) + + // Ensure that we get metas on refresh once we are connected + useEffect(() => { + if (hasSession && isConnected && !metas?.meta) { + refetchMetas() + setUpdated(new Date().toLocaleString()) + } + }, [hasSession, isConnected, metas, refetchMetas]) + + // Refetch metas every 30 seconds + useEffect(() => { + const heartbeatInterval = setInterval(() => { + refetchMetas() + setUpdated(new Date().toLocaleString()) + if (!isMetaLoading && !metas?.meta) { + setError(true) + } + }, 30000) + + return () => { + clearInterval(heartbeatInterval) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + if (!hasSession && !isConnected && isMetaLoading) { + return ( + + + + ) + } + + return ( +
+ + + + System Status + + Last Updated: {updated} + + + + {metas?.meta && metas.meta.length > 0 && ( + 1 ? "s" : "" + } Active` + } + /> + )} + + +
+ ) +} +export default Status From d75abef2b86a4cba06efbceb2aab769487cf301a Mon Sep 17 00:00:00 2001 From: nblenke Date: Mon, 25 Mar 2024 18:55:27 -0400 Subject: [PATCH 2/2] fix: refactor to use error from hooks --- __tests__/pages/status.test.tsx | 60 ++++++++++++++++++++++++++------- lib/ghostcloud.ts | 6 ++-- pages/status.tsx | 47 ++++++++++++++------------ 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/__tests__/pages/status.test.tsx b/__tests__/pages/status.test.tsx index 79f1b06..0cdb8ae 100644 --- a/__tests__/pages/status.test.tsx +++ b/__tests__/pages/status.test.tsx @@ -4,7 +4,7 @@ import { render, screen } from "@testing-library/react" import Status from "../../pages/status" import useWeb3AuthStore from "../../store/web3-auth" import useOpenLoginSession from "../../hooks/useOpenLoginSession" -import { useFetchMetas } from "../../lib/ghostcloud" +import { useFetchBalance, useFetchMetas } from "../../lib/ghostcloud" import { useRouter } from "next/router" jest.mock("@chakra-ui/react", () => ({ @@ -25,8 +25,12 @@ jest.mock("react-query", () => ({ })) jest.mock("../../hooks/useOpenLoginSession", () => jest.fn()) jest.mock("../../lib/ghostcloud", () => ({ + useFetchBalance: jest.fn(() => ({ + error: null, + })), useFetchMetas: jest.fn(() => ({ data: { meta: [] }, + error: null, isLoading: false, refetch: jest.fn(), })), @@ -46,10 +50,10 @@ describe("Status", () => { it("should redirect to homepage if not connected", async () => { const pushMock = jest.fn() useRouter.mockReturnValue({ push: pushMock }) - useWeb3AuthStore.mockReturnValueOnce({ + useWeb3AuthStore.mockReturnValue({ isConnected: jest.fn().mockReturnValue(false), }) - useFetchMetas.mockReturnValueOnce([ + useFetchMetas.mockReturnValue([ { data: { meta: [] }, isLoading: false, refetch: jest.fn() }, ]) render() @@ -57,11 +61,11 @@ describe("Status", () => { }) it("should show spinner if loading", async () => { - useWeb3AuthStore.mockReturnValueOnce({ + useWeb3AuthStore.mockReturnValue({ isConnected: jest.fn().mockReturnValue(false), }) - useOpenLoginSession.mockReturnValueOnce(false) - useFetchMetas.mockReturnValueOnce([ + useOpenLoginSession.mockReturnValue(false) + useFetchMetas.mockReturnValue([ { data: { meta: [] }, isLoading: true, refetch: jest.fn() }, ]) render() @@ -69,23 +73,36 @@ describe("Status", () => { }) it("should render service status", async () => { - useWeb3AuthStore.mockReturnValueOnce({ - isConnected: jest.fn().mockReturnValue(true), + useWeb3AuthStore.mockReturnValue({ + isConnected: jest.fn().mockReturnValue(false), }) - useOpenLoginSession.mockReturnValueOnce(true) - useFetchMetas.mockReturnValueOnce([ + useOpenLoginSession.mockReturnValue(true) + useFetchMetas.mockReturnValue([ { data: { meta: [] }, isLoading: false, refetch: jest.fn() }, ]) render() expect(screen.getByText("All Systems Operational")).toBeInTheDocument() }) + it("should not render service status if balance error", async () => { + useWeb3AuthStore.mockReturnValue({ + isConnected: jest.fn().mockReturnValue(false), + }) + useOpenLoginSession.mockReturnValue(true) + useFetchBalance.mockReturnValue([{ error: new Error("error") }]) + useFetchMetas.mockReturnValue([ + { data: { meta: [] }, error: null, isLoading: false, refetch: jest.fn() }, + ]) + render() + expect(screen.getByText("All Systems Operational")).toBeInTheDocument() + }) + it("should render deployment status", async () => { - useWeb3AuthStore.mockReturnValueOnce({ + useWeb3AuthStore.mockReturnValue({ isConnected: jest.fn().mockReturnValue(true), }) - useOpenLoginSession.mockReturnValueOnce(true) - useFetchMetas.mockReturnValueOnce([ + useOpenLoginSession.mockReturnValue(true) + useFetchMetas.mockReturnValue([ { data: { meta: [{}], pagination: { total: 1 } }, isLoading: false, @@ -95,4 +112,21 @@ describe("Status", () => { render() expect(screen.getByText("1 Deployment Active")).toBeInTheDocument() }) + + it("should not render deployment status if metas error", async () => { + useWeb3AuthStore.mockReturnValue({ + isConnected: jest.fn().mockReturnValue(true), + }) + useOpenLoginSession.mockReturnValue(true) + useFetchMetas.mockReturnValue([ + { + data: { meta: [{}] }, + error: new Error("error"), + isLoading: false, + refetch: jest.fn(), + }, + ]) + render() + expect(screen.getByText("Deployments Degraded")).toBeInTheDocument() + }) }) diff --git a/lib/ghostcloud.ts b/lib/ghostcloud.ts index df9b8b0..c317011 100644 --- a/lib/ghostcloud.ts +++ b/lib/ghostcloud.ts @@ -333,7 +333,9 @@ export const useFetchMetas = ( return [query, page + 1, pageCount, handlePageClick] } -export const useFetchBalance = (): UseQueryResult => { +export const useFetchBalance = ( + showError: boolean = true, +): UseQueryResult => { const store = useWeb3AuthStore() const displayError = useDisplayError() @@ -364,7 +366,7 @@ export const useFetchBalance = (): UseQueryResult => { queryKey: "balance", queryFn: fetchBalance, onError: error => { - displayError("Failed to fetch balance", error) + showError && displayError("Failed to fetch balance", error) }, }) } diff --git a/pages/status.tsx b/pages/status.tsx index b125ad5..03a9c2f 100644 --- a/pages/status.tsx +++ b/pages/status.tsx @@ -1,4 +1,4 @@ -import { useFetchMetas } from "../lib/ghostcloud" +import { useFetchBalance, useFetchMetas } from "../lib/ghostcloud" import { useEffect, useState } from "react" import useWeb3AuthStore from "../store/web3-auth" import useOpenLoginSession from "../hooks/useOpenLoginSession" @@ -46,12 +46,20 @@ const Status = () => { const store = useWeb3AuthStore() const isConnected = store.isConnected() const router = useRouter() - const [{ data: metas, isLoading: isMetaLoading, refetch: refetchMetas }] = - useFetchMetas(false) + const [ + { + data: metas, + error: metaError, + isLoading: isMetaLoading, + refetch: refetchMetas, + }, + ] = useFetchMetas(false) const { pagination: { total = 0 } = {} } = metas || {} - const [error, setError] = useState(false) + const [hasMetaError, setHasMetaError] = useState(false) + const [hasBalanceError, setHasBalanceError] = useState(false) const [updated, setUpdated] = useState("") const hasSession = useOpenLoginSession() + const { error: balanceError } = useFetchBalance(false) // Redirect if not logged in useEffect(() => { @@ -68,20 +76,15 @@ const Status = () => { } }, [hasSession, isConnected, metas, refetchMetas]) - // Refetch metas every 30 seconds useEffect(() => { - const heartbeatInterval = setInterval(() => { - refetchMetas() - setUpdated(new Date().toLocaleString()) - if (!isMetaLoading && !metas?.meta) { - setError(true) - } - }, 30000) + setUpdated(new Date().toLocaleString()) + setHasBalanceError(!!balanceError) + }, [balanceError]) - return () => { - clearInterval(heartbeatInterval) - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + setUpdated(new Date().toLocaleString()) + setHasMetaError(!!metaError) + }, [metaError]) if (!hasSession && !isConnected && isMetaLoading) { return ( @@ -101,16 +104,18 @@ const Status = () => { Last Updated: {updated} {metas?.meta && metas.meta.length > 0 && ( 1 ? "s" : "" } Active`