Skip to content
This repository was archived by the owner on Jul 8, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/Dashboard.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 = {
"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<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 numberDiff line numberDiff 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 numberDiff line numberDiff 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