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 ( <div className="flex-col"> - <div className="flex flex-wrap items-center gap-4 w-full"> - <div className="min-w-80 w-1/3 h-60"> - <BarChart data={alerts} loading={loading} /> - </div> - <div className="min-w-80 w-1/4 h-60"> - <PieChart data={maliciousPackages} loading={loading} /> - </div> - <div className="relative w-[370px] h-60"> - <LineChart data={alerts} loading={loading} /> - </div> + <div className="grid 2xl:grid-cols-4 sm:grid-cols-2 grid-cols-1 items-stretch gap-4 w-full"> + <CardCodegateStatus /> + <BarChart data={alerts} loading={loading} /> + <PieChart data={maliciousPackages} loading={loading} /> + <LineChart data={alerts} loading={loading} /> </div> <Separator className="my-8" /> @@ -193,7 +189,7 @@ export function Dashboard() { </div> </div> <div className="overflow-x-auto"> - <Table> + <Table data-testid="alerts-table"> <TableHeader> <TableRow> <TableHead className="w-[150px]">Trigger Type</TableHead> 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<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn("p-4 pt-0", className)} {...props} /> -)) -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(<CardCodegateStatus />); + +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<Status | null> => { + 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 ( + <div className="flex gap-2 items-center text-gray-600 justify-end overflow-hidden"> + Checking <LoaderCircle className="size-4 animate-spin" /> + </div> + ); + } + + switch (status) { + case Status.HEALTHY: + return ( + <div className="flex gap-2 items-center text-green-600 justify-end"> + {Status.HEALTHY} <CheckCircle2 className="size-4" /> + </div> + ); + case Status.UNHEALTHY: + return ( + <div className="flex gap-2 items-center text-red-600 justify-end overflow-hidden"> + {Status.UNHEALTHY} <XCircle className="size-4" /> + </div> + ); + default: { + status satisfies never; + } + } +}; + +function ErrorUI() { + return ( + <div className="flex flex-col items-center justify-center py-8"> + <XCircle className="text-red-600 mb-2 size-8" /> + <div className="text-md font-semibold text-gray-600 text-center"> + An error occurred + </div> + <div className="text-sm text-gray-600 text-center text-balance"> + If this issue persists, please reach out to us on{" "} + <a + className="underline text-gray-700" + href="https://discord.gg/stacklok" + rel="noopener noreferrer" + target="_blank" + > + Discord + </a>{" "} + or open a new{" "} + <a + className="underline text-gray-700" + href="https://github.com/stacklok/codegate/issues/new" + rel="noopener noreferrer" + target="_blank" + > + Github issue + </a> + </div> + </div> + ); +} + +function PollIntervalControl({ + className, + pollingInterval, + setPollingInterval, +}: { + className?: string; + pollingInterval: Interval; + setPollingInterval: Dispatch<SetStateAction<Interval>>; +}) { + return ( + <div className={cn("flex items-center relative group", className)}> + <div className="text-gray-600 hover:text-gray-800 font-normal cursor-pointer text-base px-2 py-1 rounded-md hover:bg-blue-50 transition-colors flex gap-1 items-center"> + <div> + <div className="text-sm font-semibold text-gray-500 text-right"> + Check for updates + </div> + <div className="text-sm text-gray-500 text-right"> + every {INTERVAL[pollingInterval].name} + </div> + </div> + <ChevronDown className="size-4" /> + </div> + <div className="p-1 absolute right-0 top-full mt-2 w-32 bg-white rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 border border-gray-100"> + {Object.entries(INTERVAL).map(([key, { name }]) => { + const isActive = key === pollingInterval; + + return ( + <button + onClick={() => setPollingInterval(key as Interval)} + data-active={isActive} + className="text-right :not:last:mb-1 font-normal text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-0.5 hover:text-gray-800 &[data-active=true]:text-gray-800 flex items-center justify-between w-full" + key={key} + > + {name} + {isActive ? <Check className="size-3" /> : null} + </button> + ); + })} + </div> + </div> + ); +} + +export function InnerContent({ + isError, + isPending, + data, +}: Pick<ReturnType<typeof useStatus>, "data" | "isPending" | "isError">) { + if (!isPending && isError) { + return <ErrorUI />; + } + + return ( + <Table className="h-max"> + <TableBody> + <TableRow className="hover:bg-transparent"> + <TableCell className="pl-0">CodeGate server</TableCell> + <TableCell className="pr-0 text-end"> + <StatusText isPending={isPending} status={data ?? null} /> + </TableCell> + </TableRow> + </TableBody> + </Table> + ); +} + +export function CardCodegateStatus() { + const [pollingInterval, setPollingInterval] = useState<Interval>( + () => DEFAULT_INTERVAL, + ); + const { data, dataUpdatedAt, isPending, isError } = + useStatus(pollingInterval); + + return ( + <Card className="h-full flex flex-col"> + <CardHeader> + <CardTitle className="flex justify-between items-center"> + <span className="block">CodeGate Status</span> + </CardTitle> + </CardHeader> + + <CardContent className="h-max"> + <InnerContent data={data} isPending={isPending} isError={isError} /> + </CardContent> + + <CardFooter className="border-t border-gray-200 mt-auto py-2 pr-2"> + <div> + <div className="text-sm font-semibold text-gray-500"> + Last checked + </div> + <div className="text-sm text-gray-500"> + {format(new Date(dataUpdatedAt), "pp")} + </div> + </div> + + <PollIntervalControl + className="ml-auto" + pollingInterval={pollingInterval} + setPollingInterval={setPollingInterval} + /> + </CardFooter> + </Card> + ); +} 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<RenderOptions, "queries"> & RoutConfig, ) => render( - <MemoryRouter {...options?.routeConfig}> - <Routes> - <Route - path={options?.pathConfig ?? "*"} - element={<SidebarProvider>{children}</SidebarProvider>} - /> - </Routes> - </MemoryRouter>, + <QueryClientProvider + client={ + new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + staleTime: 0, + }, + }, + }) + } + > + <MemoryRouter {...options?.routeConfig}> + <Routes> + <Route + path={options?.pathConfig ?? "*"} + element={<SidebarProvider>{children}</SidebarProvider>} + /> + </Routes> + </MemoryRouter> + </QueryClientProvider>, ); 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( <BrowserRouter> <SidebarProvider> <ErrorBoundary fallback={<Error />}> - <App /> + <QueryClientProvider client={new QueryClient()}> + <App /> + </QueryClientProvider> </ErrorBoundary> </SidebarProvider> </BrowserRouter> 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({ <CardHeader> <CardTitle>Alerts by date</CardTitle> </CardHeader> - <CardContent className="h-full"> - <ChartContainer config={chartConfig}> + <CardContent> + <ChartContainer config={chartConfig} className="min-h-[10rem]"> <LineChartsUI accessibilityLayer data={chartData}