Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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