Skip to content
This repository was archived by the owner on Jul 8, 2025. It is now read-only.

feat: initial work on codegate version check widget #94

Merged
merged 8 commits into from
Jan 17, 2025
4 changes: 2 additions & 2 deletions src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -132,7 +132,7 @@ export function Dashboard() {
return (
<div className="flex-col">
<div className="grid 2xl:grid-cols-4 sm:grid-cols-2 grid-cols-1 items-stretch gap-4 w-full">
<CardCodegateStatus />
<CodegateStatus />
<BarChart data={alerts} loading={isLoading} />
<PieChart data={maliciousPackages} loading={isLoading} />
<LineChart data={alerts} loading={isLoading} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<CodegateStatus />);

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 },
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { XCircle } from "lucide-react";

export function CodegateStatusErrorUI() {
return (
<div className="flex flex-col items-center justify-center py-8">
<XCircle className="text-red-600 mb-2 size-8" />
<div className="text-base font-semibold text-secondary text-center">
An error occurred
</div>
<div className="text-sm text-secondary text-center text-balance">
If this issue persists, please reach out to us on{" "}
<a
className="underline text-secondary"
href="https://discord.gg/stacklok"
rel="noopener noreferrer"
target="_blank"
>
Discord
</a>{" "}
or open a new{" "}
<a
className="underline text-secondary"
href="https://github.com/stacklok/codegate/issues/new"
rel="noopener noreferrer"
target="_blank"
>
Github issue
</a>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-2 items-center text-secondary justify-end overflow-hidden">
Checking <LoaderCircle className="size-4 shrink-0 animate-spin" />
</div>
);
}

switch (data) {
case HealthStatus.HEALTHY:
return (
<div className="flex gap-2 items-center text-primary justify-end">
{HealthStatus.HEALTHY} <CheckCircle2 className="size-4 shrink-0" />
</div>
);
case HealthStatus.UNHEALTHY:
return (
<div className="flex gap-2 items-center text-primary justify-end overflow-hidden">
{HealthStatus.UNHEALTHY} <XCircle className="size-4 shrink-0" />
</div>
);
default: {
data satisfies never;
}
}
};
Original file line number Diff line number Diff line change
@@ -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 = {

Check warning on line 11 in src/features/dashboard-codegate-status/components/codegate-status-polling-control.tsx

View workflow job for this annotation

GitHub Actions / Static Checks / ESLint Check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
"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(

Check warning on line 17 in src/features/dashboard-codegate-status/components/codegate-status-polling-control.tsx

View workflow job for this annotation

GitHub Actions / Static Checks / ESLint Check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
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<SetStateAction<PollingInterval>>;
}) {
return (
<Select
className={className}
onSelectionChange={(v) =>
setPollingInterval(v.toString() as PollingInterval)
}
items={INTERVAL_SELECT_ITEMS}
defaultSelectedKey={pollingInterval}
>
<Label className="w-full text-right font-semibold text-secondary -mb-1">
Check for updates
</Label>
<SelectButton
isBorderless
className="h-7 max-w-36 pr-0 [&>span>span]:text-right [&>span>span]:justify-end !gap-0 text-secondary"
/>
</Select>
);
}
Original file line number Diff line number Diff line change
@@ -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<boolean>(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 (
<Button
onPress={handleRefresh}
variant="tertiary"
className={twMerge("size-7", className)}
isDisabled={refreshed}
>
<RefreshCcw className={refreshed ? "animate-spin-once" : undefined} />
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-2 items-center text-secondary justify-end overflow-hidden">
Checking <LoaderCircle className="size-4 shrink-0 animate-spin" />
</div>
);
}

const { current_version, is_latest, latest_version, error } = data || {};

if (error !== null || is_latest === null) {
return (
<div className="flex gap-2 items-center text-primary justify-end overflow-hidden">
Error checking version <XCircle className="size-4 shrink-0" />
</div>
);
}

switch (is_latest) {
case true:
return (
<div className="flex gap-2 items-center text-primary justify-end">
Latest <CheckCircle2 className="size-4 shrink-0" />
</div>
);
case false:
return (
<div>
<TooltipTrigger delay={0}>
<Link
className="flex gap-2 items-center text-primary justify-end overflow-hidden"
variant="secondary"
target="_blank"
rel="noopener noreferrer"
href="https://docs.codegate.ai/how-to/install#upgrade-codegate"
>
Update available <CircleAlert className="size-4 shrink-0" />
</Link>
<Tooltip className="text-right">
<span className="block">Current version: {current_version}</span>
<span className="block">Latest version: {latest_version}</span>
</Tooltip>
</TooltipTrigger>
</div>
);
default: {
is_latest satisfies never;
}
}
};
Loading
Loading