diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index d41aa8d9..8b54337e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -23,7 +23,7 @@ import { Tooltip, TooltipTrigger } from "@stacklok/ui-kit"; 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"; +import { CodegateStatus } from "@/features/dashboard-codegate-status/components/codegate-status"; import { Search } from "lucide-react"; import { useAlertsData, @@ -132,7 +132,7 @@ export function Dashboard() { return (
- + diff --git a/src/features/dashboard-codegate-status/components/__tests__/codegate-status.test.tsx b/src/features/dashboard-codegate-status/components/__tests__/codegate-status.test.tsx new file mode 100644 index 00000000..d4704b8d --- /dev/null +++ b/src/features/dashboard-codegate-status/components/__tests__/codegate-status.test.tsx @@ -0,0 +1,134 @@ +import { server } from "@/mocks/msw/node"; +import { http, HttpResponse } from "msw"; +import { expect } from "vitest"; +import { CodegateStatus } from "../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 when health check request fails", async () => { + server.use(http.get("*/health", () => HttpResponse.error())); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/an error occurred/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); + + test("renders 'error' state when version check request fails", async () => { + server.use(http.get("*/dashboard/version", () => HttpResponse.error())); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/an error occurred/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); + + test("renders 'latest version' state", async () => { + server.use( + http.get("*/dashboard/version", () => + HttpResponse.json({ + current_version: "foo", + latest_version: "foo", + is_latest: true, + error: null, + }), + ), + ); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/latest/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); + + test("renders 'update available' state", async () => { + server.use( + http.get("*/dashboard/version", () => + HttpResponse.json({ + current_version: "foo", + latest_version: "bar", + is_latest: false, + error: null, + }), + ), + ); + + const { getByRole } = renderComponent(); + + await waitFor( + () => { + const role = getByRole("link", { name: /update available/i }); + expect(role).toBeVisible(); + expect(role).toHaveAttribute( + "href", + "https://docs.codegate.ai/how-to/install#upgrade-codegate", + ); + }, + { timeout: 10_000 }, + ); + }); + + test("renders 'version check error' state", async () => { + server.use( + http.get("*/dashboard/version", () => + HttpResponse.json({ + current_version: "foo", + latest_version: "bar", + is_latest: false, + error: "foo", + }), + ), + ); + + const { getByText } = renderComponent(); + + await waitFor( + () => { + expect(getByText(/error checking version/i)).toBeVisible(); + }, + { timeout: 10_000 }, + ); + }); +}); diff --git a/src/features/dashboard-codegate-status/components/codegate-status-error-ui.tsx b/src/features/dashboard-codegate-status/components/codegate-status-error-ui.tsx new file mode 100644 index 00000000..bad6810a --- /dev/null +++ b/src/features/dashboard-codegate-status/components/codegate-status-error-ui.tsx @@ -0,0 +1,32 @@ +import { XCircle } from "lucide-react"; + +export function CodegateStatusErrorUI() { + return ( +
+ +
+ An error occurred +
+
+ If this issue persists, please reach out to us on{" "} + + Discord + {" "} + or open a new{" "} + + Github issue + +
+
+ ); +} diff --git a/src/features/dashboard-codegate-status/components/codegate-status-health.tsx b/src/features/dashboard-codegate-status/components/codegate-status-health.tsx new file mode 100644 index 00000000..cbf0d99e --- /dev/null +++ b/src/features/dashboard-codegate-status/components/codegate-status-health.tsx @@ -0,0 +1,36 @@ +import { LoaderCircle, CheckCircle2, XCircle } from "lucide-react"; +import { HealthStatus } from "../lib/get-codegate-health"; + +export const CodegateStatusHealth = ({ + data: data, + isPending, +}: { + data: HealthStatus | null; + isPending: boolean; +}) => { + if (isPending || data === null) { + return ( +
+ Checking +
+ ); + } + + switch (data) { + case HealthStatus.HEALTHY: + return ( +
+ {HealthStatus.HEALTHY} +
+ ); + case HealthStatus.UNHEALTHY: + return ( +
+ {HealthStatus.UNHEALTHY} +
+ ); + default: { + data satisfies never; + } + } +}; diff --git a/src/features/dashboard-codegate-status/components/codegate-status-polling-control.tsx b/src/features/dashboard-codegate-status/components/codegate-status-polling-control.tsx new file mode 100644 index 00000000..b9e01e5c --- /dev/null +++ b/src/features/dashboard-codegate-status/components/codegate-status-polling-control.tsx @@ -0,0 +1,54 @@ +import { Dispatch, SetStateAction } from "react"; +import { + Label, + Select, + SelectButton, + TDropdownItemOrSection, +} from "@stacklok/ui-kit"; + +// NOTE: We don't poll more than once per minute, as the server depends on +// Github's public API, which is rate limited to 60reqs per hour. +export const POLLING_INTERVAl = { + "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; + +export const INTERVAL_SELECT_ITEMS: TDropdownItemOrSection[] = Object.entries( + POLLING_INTERVAl, +).map(([key, { name }]) => { + return { textValue: name, id: key }; +}); + +export const DEFAULT_INTERVAL: PollingInterval = "5_MIN"; + +export type PollingInterval = keyof typeof POLLING_INTERVAl; + +export function PollIntervalControl({ + className, + pollingInterval, + setPollingInterval, +}: { + className?: string; + pollingInterval: PollingInterval; + setPollingInterval: Dispatch>; +}) { + return ( + + ); +} diff --git a/src/features/dashboard-codegate-status/components/codegate-status-refresh-button.tsx b/src/features/dashboard-codegate-status/components/codegate-status-refresh-button.tsx new file mode 100644 index 00000000..ceb73746 --- /dev/null +++ b/src/features/dashboard-codegate-status/components/codegate-status-refresh-button.tsx @@ -0,0 +1,41 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { PollingInterval } from "./codegate-status-polling-control"; +import { getQueryOptionsCodeGateStatus } from "../hooks/use-codegate-status"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@stacklok/ui-kit"; +import { RefreshCcw } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +export function CodeGateStatusRefreshButton({ + pollingInterval, + className, +}: { + pollingInterval: PollingInterval; + className?: string; +}) { + const queryClient = useQueryClient(); + const { queryKey } = getQueryOptionsCodeGateStatus(pollingInterval); + + const [refreshed, setRefreshed] = useState(false); + + useEffect(() => { + const id = setTimeout(() => setRefreshed(false), 500); + return () => clearTimeout(id); + }, [refreshed]); + + const handleRefresh = useCallback(() => { + setRefreshed(true); + return queryClient.invalidateQueries({ queryKey, refetchType: "all" }); + }, [queryClient, queryKey]); + + return ( + + ); +} diff --git a/src/features/dashboard-codegate-status/components/codegate-status-version.tsx b/src/features/dashboard-codegate-status/components/codegate-status-version.tsx new file mode 100644 index 00000000..65cbadfc --- /dev/null +++ b/src/features/dashboard-codegate-status/components/codegate-status-version.tsx @@ -0,0 +1,61 @@ +import { LoaderCircle, CheckCircle2, CircleAlert, XCircle } from "lucide-react"; +import { VersionResponse } from "../lib/get-version-status"; +import { Link, Tooltip, TooltipTrigger } from "@stacklok/ui-kit"; + +export const CodegateStatusVersion = ({ + data, + isPending, +}: { + data: VersionResponse | null; + isPending: boolean; +}) => { + if (isPending || data === null) { + return ( +
+ Checking +
+ ); + } + + const { current_version, is_latest, latest_version, error } = data || {}; + + if (error !== null || is_latest === null) { + return ( +
+ Error checking version +
+ ); + } + + switch (is_latest) { + case true: + return ( +
+ Latest +
+ ); + case false: + return ( +
+ + + Update available + + + Current version: {current_version} + Latest version: {latest_version} + + +
+ ); + default: { + is_latest satisfies never; + } + } +}; diff --git a/src/features/dashboard-codegate-status/components/codegate-status.tsx b/src/features/dashboard-codegate-status/components/codegate-status.tsx new file mode 100644 index 00000000..4464db95 --- /dev/null +++ b/src/features/dashboard-codegate-status/components/codegate-status.tsx @@ -0,0 +1,112 @@ +import { + Card, + CardBody, + CardFooter, + CardHeader, + CardTitle, + Cell, + Column, + Row, + Table, + TableBody, + TableHeader, +} from "@stacklok/ui-kit"; + +import { format } from "date-fns"; +import { useState } from "react"; +import { useCodeGateStatus } from "../hooks/use-codegate-status"; +import { CodegateStatusErrorUI } from "./codegate-status-error-ui"; +import { + DEFAULT_INTERVAL, + PollingInterval, + PollIntervalControl, +} from "./codegate-status-polling-control"; +import { CodegateStatusHealth } from "./codegate-status-health"; +import { CodegateStatusVersion } from "./codegate-status-version"; +import { CodeGateStatusRefreshButton } from "./codegate-status-refresh-button"; + +export function InnerContent({ + isError, + isPending, + data, +}: Pick< + ReturnType, + "data" | "isPending" | "isError" +>) { + if (!isPending && isError) { + return ; + } + + const { health, version } = data || {}; + + return ( + + + Name + Value + + + + CodeGate server + + + + + + + CodeGate version + + + + + +
+ ); +} + +export function CodegateStatus() { + const [pollingInterval, setPollingInterval] = useState( + () => DEFAULT_INTERVAL, + ); + const { data, dataUpdatedAt, isPending, isError } = + useCodeGateStatus(pollingInterval); + + return ( + + + + CodeGate Status + + + + + + + + + + +
+
+ Last checked +
+
+ {format(new Date(dataUpdatedAt), "pp")} +
+
+ + +
+
+ ); +} diff --git a/src/features/dashboard-codegate-status/hooks/use-codegate-status.ts b/src/features/dashboard-codegate-status/hooks/use-codegate-status.ts new file mode 100644 index 00000000..bf7712de --- /dev/null +++ b/src/features/dashboard-codegate-status/hooks/use-codegate-status.ts @@ -0,0 +1,38 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; + +import { getCodeGateHealth } from "../lib/get-codegate-health"; +import { getVersionStatus } from "../lib/get-version-status"; +import { + PollingInterval, + POLLING_INTERVAl, +} from "../components/codegate-status-polling-control"; + +export function getQueryOptionsCodeGateStatus( + pollingInterval: PollingInterval, +) { + return queryOptions({ + queryFn: async () => { + const health = await getCodeGateHealth(); + const version = await getVersionStatus(); + + return { + health, + version, + }; + }, + queryKey: ["useHealthCheck", { pollingInterval }], + refetchInterval: POLLING_INTERVAl[pollingInterval].value, + staleTime: Infinity, + gcTime: Infinity, + refetchIntervalInBackground: true, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + retry: false, + }); +} + +export const useCodeGateStatus = (pollingInterval: PollingInterval) => + useQuery({ + ...getQueryOptionsCodeGateStatus(pollingInterval), + }); diff --git a/src/features/dashboard-codegate-status/lib/get-codegate-health.ts b/src/features/dashboard-codegate-status/lib/get-codegate-health.ts new file mode 100644 index 00000000..a22f521c --- /dev/null +++ b/src/features/dashboard-codegate-status/lib/get-codegate-health.ts @@ -0,0 +1,18 @@ +export enum HealthStatus { + HEALTHY = "Healthy", + UNHEALTHY = "Unhealthy", +} + +type HealthResponse = { status: "healthy" | unknown } | null; + +export const getCodeGateHealth = async (): Promise => { + const resp = await fetch( + new URL("/health", import.meta.env.VITE_BASE_API_URL), + ); + const data = (await resp.json()) as unknown as HealthResponse; + + if (data?.status === "healthy") return HealthStatus.HEALTHY; + if (data?.status !== "healthy") return HealthStatus.UNHEALTHY; + + return null; +}; diff --git a/src/features/dashboard-codegate-status/lib/get-version-status.ts b/src/features/dashboard-codegate-status/lib/get-version-status.ts new file mode 100644 index 00000000..287113f3 --- /dev/null +++ b/src/features/dashboard-codegate-status/lib/get-version-status.ts @@ -0,0 +1,15 @@ +export type VersionResponse = { + current_version: string; + latest_version: string; + is_latest: boolean | null; + error: string | null; +} | null; + +export const getVersionStatus = async (): Promise => { + const resp = await fetch( + new URL("/dashboard/version", import.meta.env.VITE_BASE_API_URL), + ); + const data = (await resp.json()) as unknown as VersionResponse; + + return data; +}; diff --git a/src/features/dashboard/components/__tests__/card-codegate-status.test.tsx b/src/features/dashboard/components/__tests__/card-codegate-status.test.tsx deleted file mode 100644 index 838cc424..00000000 --- a/src/features/dashboard/components/__tests__/card-codegate-status.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 9ed02518..00000000 --- a/src/features/dashboard/components/card-codegate-status.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - Card, - CardBody, - CardFooter, - CardHeader, - CardTitle, - Cell, - Column, - Label, - Row, - Select, - SelectButton, - Table, - TableBody, - TableHeader, - TDropdownItemOrSection, -} from "@stacklok/ui-kit"; - -import { useQuery } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { CheckCircle2, LoaderCircle, XCircle } 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 INTERVAL_SELECT_ITEMS: TDropdownItemOrSection[] = Object.entries( - INTERVAL, -).map(([key, { name }]) => { - return { textValue: name, id: key }; -}); - -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 ( - - ); -} - -export function InnerContent({ - isError, - isPending, - data, -}: Pick, "data" | "isPending" | "isError">) { - if (!isPending && isError) { - return ; - } - - return ( - - - Name - Value - - - - 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/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index a2277a5a..46e62cd6 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -3,7 +3,17 @@ 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("*/health", () => + HttpResponse.json({ + current_version: "foo", + latest_version: "bar", + is_latest: false, + error: null, + }), + ), + http.get("*/dashboard/version", () => + HttpResponse.json({ status: "healthy" }), + ), http.get("*/dashboard/messages", () => { return HttpResponse.json(mockedPrompts); }), diff --git a/tailwind.config.ts b/tailwind.config.ts index 7b2c8144..d2d469f2 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -10,6 +10,19 @@ export default { theme: { ...stacklokTailwindPreset.theme, extend: { + animation: { + "spin-once": "spin-once 0.5s ease-in-out", + }, + keyframes: { + "spin-once": { + "0%": { + transform: "rotate(0deg)", + }, + "100%": { + transform: "rotate(180deg)", + }, + }, + }, typography: () => ({ DEFAULT: { css: {