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