From f2673bcf71d2ce1850ac9ad8e861bf448ac84a91 Mon Sep 17 00:00:00 2001 From: Alex McGovern <58784948+alex-mcgovern@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:21:21 +0000 Subject: [PATCH] feat: health check card (#62) * feat: initial work on health-check card * feat: tidy up health check card * feat: add ability to control polling interval * fix: use correct referrer type for error UI links * fix: add default health-check endpoint handler * test: fix failing tests after introducing second table to dashboard page --- package-lock.json | 27 ++ package.json | 3 +- src/components/Dashboard.tsx | 18 +- src/components/__tests__/Dashboard.test.tsx | 26 +- src/components/ui/card.tsx | 41 +-- .../__tests__/card-codegate-status.test.tsx | 50 ++++ .../components/card-codegate-status.tsx | 236 ++++++++++++++++++ src/lib/test-utils.tsx | 30 ++- src/main.tsx | 5 +- src/mocks/msw/handlers.ts | 1 + src/viz/LineChart.tsx | 4 +- 11 files changed, 390 insertions(+), 51 deletions(-) create mode 100644 src/features/dashboard/components/__tests__/card-codegate-status.test.tsx create mode 100644 src/features/dashboard/components/card-codegate-status.tsx diff --git a/package-lock.json b/package-lock.json index 9161c4e2..9d146221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@tanstack/react-query": "^5.64.1", "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", @@ -2725,6 +2726,32 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" } }, + "node_modules/@tanstack/query-core": { + "version": "5.64.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz", + "integrity": "sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.64.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz", + "integrity": "sha512-vW5ggHpIO2Yjj44b4sB+Fd3cdnlMJppXRBJkEHvld6FXh3j5dwWJoQo7mGtKI2RbSFyiyu/PhGAy0+Vv5ev9Eg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.64.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", diff --git a/package.json b/package.json index 027080d1..877163df 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@tanstack/react-query": "^5.64.1", "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", @@ -81,4 +82,4 @@ "overrides": { "vite": "^6.0.1" } -} \ No newline at end of file +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ba200ee7..938a2fac 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -21,6 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { useSearchParams } from "react-router-dom"; import { AlertConversation } from "@/api/generated"; import { getMaliciousPackage } from "@/lib/utils"; +import { CardCodegateStatus } from "@/features/dashboard/components/card-codegate-status"; const wrapObjectOutput = (input: AlertConversation["trigger_string"]) => { const data = getMaliciousPackage(input); @@ -127,16 +128,11 @@ export function Dashboard() { return (
-
-
- -
-
- -
-
- -
+
+ + + +
@@ -193,7 +189,7 @@ export function Dashboard() {
- +
Trigger Type diff --git a/src/components/__tests__/Dashboard.test.tsx b/src/components/__tests__/Dashboard.test.tsx index 456100d9..1be53c1a 100644 --- a/src/components/__tests__/Dashboard.test.tsx +++ b/src/components/__tests__/Dashboard.test.tsx @@ -154,19 +154,21 @@ describe("Dashboard", () => { ).toBeVisible(); expect(screen.getByRole("searchbox")).toBeVisible(); - const row = screen.getAllByRole("row")[1] as HTMLElement; + const firstRow = within(screen.getByTestId("alerts-table")).getAllByRole( + "row", + )[1] as HTMLElement; + const secondRow = within(screen.getByTestId("alerts-table")).getAllByRole( + "row", + )[2] as HTMLElement; - expect(within(row).getByText(/ghp_token/i)).toBeVisible(); - expect(within(row).getByText(/codegate-secrets/i)).toBeVisible(); - expect(within(row).getAllByText(/n\/a/i).length).toEqual(2); - expect(within(row).getByText(/2025\/01\/07/i)).toBeVisible(); - expect(within(row).getByTestId(/time/i)).toBeVisible(); + expect(within(firstRow).getByText(/ghp_token/i)).toBeVisible(); + expect(within(firstRow).getByText(/codegate-secrets/i)).toBeVisible(); + expect(within(firstRow).getAllByText(/n\/a/i).length).toEqual(2); + expect(within(firstRow).getByText(/2025\/01\/07/i)).toBeVisible(); + expect(within(firstRow).getByTestId(/time/i)).toBeVisible(); // check trigger_string null - expect( - within(screen.getAllByRole("row")[2] as HTMLElement).getAllByText(/n\/a/i) - .length, - ).toEqual(3); + expect(within(secondRow).getAllByText(/n\/a/i).length).toEqual(3); }); it("should render malicious pkg", async () => { @@ -271,7 +273,9 @@ describe("Dashboard", () => { await waitFor(() => expect(screen.getByTestId(/alerts-count/i)).toHaveTextContent("1"), ); - const row = screen.getAllByRole("row")[1] as HTMLElement; + const row = within(screen.getByTestId("alerts-table")).getAllByRole( + "row", + )[1] as HTMLElement; expect(within(row).getByText(/ghp_token/i)).toBeVisible(); expect(within(row).getByText(/codegate-secrets/i)).toBeVisible(); }); diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 754ad93a..0b46be9e 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Card = React.forwardRef< HTMLDivElement, @@ -10,12 +10,12 @@ const Card = React.forwardRef< ref={ref} className={cn( "rounded-lg border bg-card text-card-foreground shadow-sm", - className + className, )} {...props} /> -)) -Card.displayName = "Card" +)); +Card.displayName = "Card"; const CardHeader = React.forwardRef< HTMLDivElement, @@ -26,8 +26,8 @@ const CardHeader = React.forwardRef< className={cn("flex flex-col space-y-1 p-4", className)} {...props} /> -)) -CardHeader.displayName = "CardHeader" +)); +CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< HTMLDivElement, @@ -37,12 +37,12 @@ const CardTitle = React.forwardRef< ref={ref} className={cn( "text-xl font-semibold leading-none tracking-tight", - className + className, )} {...props} /> -)) -CardTitle.displayName = "CardTitle" +)); +CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< HTMLDivElement, @@ -53,16 +53,16 @@ const CardDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -CardDescription.displayName = "CardDescription" +)); +CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
-)) -CardContent.displayName = "CardContent" +)); +CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< HTMLDivElement, @@ -73,7 +73,14 @@ const CardFooter = React.forwardRef< className={cn("flex items-center p-4 pt-0", className)} {...props} /> -)) -CardFooter.displayName = "CardFooter" +)); +CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/features/dashboard/components/__tests__/card-codegate-status.test.tsx b/src/features/dashboard/components/__tests__/card-codegate-status.test.tsx new file mode 100644 index 00000000..838cc424 --- /dev/null +++ b/src/features/dashboard/components/__tests__/card-codegate-status.test.tsx @@ -0,0 +1,50 @@ +import { server } from "@/mocks/msw/node"; +import { http, HttpResponse } from "msw"; +import { expect } from "vitest"; +import { CardCodegateStatus } from "../card-codegate-status"; +import { render, waitFor } from "@/lib/test-utils"; + +const renderComponent = () => render(); + +describe("CardCodegateStatus", () => { + test("renders 'healthy' state", async () => { + server.use( + http.get("*/health", () => HttpResponse.json({ status: "healthy" })), + ); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/healthy/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); + + test("renders 'unhealthy' state", async () => { + server.use(http.get("*/health", () => HttpResponse.json({ status: null }))); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/unhealthy/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); + + test("renders 'error' state", async () => { + server.use(http.get("*/health", () => HttpResponse.error())); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/an error occurred/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); +}); diff --git a/src/features/dashboard/components/card-codegate-status.tsx b/src/features/dashboard/components/card-codegate-status.tsx new file mode 100644 index 00000000..82d12779 --- /dev/null +++ b/src/features/dashboard/components/card-codegate-status.tsx @@ -0,0 +1,236 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { TableRow, TableBody, TableCell, Table } from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + CheckCircle2, + LoaderCircle, + XCircle, + ChevronDown, + Check, +} from "lucide-react"; +import { Dispatch, SetStateAction, useState } from "react"; + +const INTERVAL = { + "1_SEC": { value: 1_000, name: "1 second" }, + "5_SEC": { value: 5_000, name: "5 seconds" }, + "10_SEC": { value: 10_000, name: "10 seconds" }, + "30_SEC": { value: 30_000, name: "30 seconds" }, + "1_MIN": { value: 60_000, name: "1 minute" }, + "5_MIN": { value: 300_000, name: "5 minutes" }, + "10_MIN": { value: 600_000, name: "10 minutes" }, +} as const; + +const DEFAULT_INTERVAL: Interval = "5_SEC"; + +type Interval = keyof typeof INTERVAL; + +enum Status { + HEALTHY = "Healthy", + UNHEALTHY = "Unhealthy", +} + +type HealthResp = { status: "healthy" | unknown } | null; + +const getStatus = async (): Promise => { + const resp = await fetch( + new URL("/health", import.meta.env.VITE_BASE_API_URL), + ); + const data = (await resp.json()) as unknown as HealthResp; + + if (data?.status === "healthy") return Status.HEALTHY; + if (data?.status !== "healthy") return Status.UNHEALTHY; + + return null; +}; + +const useStatus = (pollingInterval: Interval) => + useQuery({ + queryFn: getStatus, + queryKey: ["getStatus", { pollingInterval }], + refetchInterval: INTERVAL[pollingInterval].value, + staleTime: Infinity, + gcTime: Infinity, + refetchIntervalInBackground: true, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + retry: false, + }); + +const StatusText = ({ + status, + isPending, +}: { + status: Status | null; + isPending: boolean; +}) => { + if (isPending || status === null) { + return ( +
+ Checking +
+ ); + } + + switch (status) { + case Status.HEALTHY: + return ( +
+ {Status.HEALTHY} +
+ ); + case Status.UNHEALTHY: + return ( +
+ {Status.UNHEALTHY} +
+ ); + default: { + status satisfies never; + } + } +}; + +function ErrorUI() { + return ( +
+ +
+ An error occurred +
+
+ If this issue persists, please reach out to us on{" "} + + Discord + {" "} + or open a new{" "} + + Github issue + +
+
+ ); +} + +function PollIntervalControl({ + className, + pollingInterval, + setPollingInterval, +}: { + className?: string; + pollingInterval: Interval; + setPollingInterval: Dispatch>; +}) { + return ( +
+
+
+
+ Check for updates +
+
+ every {INTERVAL[pollingInterval].name} +
+
+ +
+
+ {Object.entries(INTERVAL).map(([key, { name }]) => { + const isActive = key === pollingInterval; + + return ( + + ); + })} +
+
+ ); +} + +export function InnerContent({ + isError, + isPending, + data, +}: Pick, "data" | "isPending" | "isError">) { + if (!isPending && isError) { + return ; + } + + return ( +
+ + + CodeGate server + + + + + +
+ ); +} + +export function CardCodegateStatus() { + const [pollingInterval, setPollingInterval] = useState( + () => DEFAULT_INTERVAL, + ); + const { data, dataUpdatedAt, isPending, isError } = + useStatus(pollingInterval); + + return ( + + + + CodeGate Status + + + + + + + + +
+
+ Last checked +
+
+ {format(new Date(dataUpdatedAt), "pp")} +
+
+ + +
+
+ ); +} diff --git a/src/lib/test-utils.tsx b/src/lib/test-utils.tsx index c1572da7..f6880dbf 100644 --- a/src/lib/test-utils.tsx +++ b/src/lib/test-utils.tsx @@ -1,4 +1,5 @@ import { SidebarProvider } from "@/components/ui/sidebar"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RenderOptions, render } from "@testing-library/react"; import React from "react"; import { @@ -18,14 +19,27 @@ const renderWithProviders = ( options?: Omit & RoutConfig, ) => render( - - - {children}} - /> - - , + + + + {children}} + /> + + + , ); export * from "@testing-library/react"; diff --git a/src/main.tsx b/src/main.tsx index 214fd637..03661b48 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import "./index.css"; import App from "./App.tsx"; import { BrowserRouter } from "react-router-dom"; import { SidebarProvider } from "./components/ui/sidebar.tsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ErrorBoundary from "./components/ErrorBoundary.tsx"; import { Error } from "./components/Error.tsx"; @@ -12,7 +13,9 @@ createRoot(document.getElementById("root")!).render( }> - + + + diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index f52db2b0..a2277a5a 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -3,6 +3,7 @@ import mockedPrompts from "@/mocks/msw/fixtures/GET_MESSAGES.json"; import mockedAlerts from "@/mocks/msw/fixtures/GET_ALERTS.json"; export const handlers = [ + http.get("*/health", () => HttpResponse.json({ status: "healthy" })), http.get("*/dashboard/messages", () => { return HttpResponse.json(mockedPrompts); }), diff --git a/src/viz/LineChart.tsx b/src/viz/LineChart.tsx index 0edc5c79..84a52693 100644 --- a/src/viz/LineChart.tsx +++ b/src/viz/LineChart.tsx @@ -85,8 +85,8 @@ export function LineChart({ Alerts by date - - + +