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
+
+
+
+ );
+}
+
+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
-
-
+
+