Skip to content

Commit 0baa10b

Browse files
feat: initial work on codegate version check widget (#94)
* feat: initial work on codegate version check widget * fix: update tests & error handling
1 parent d873272 commit 0baa10b

15 files changed

+567
-286
lines changed

src/components/Dashboard.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { Tooltip, TooltipTrigger } from "@stacklok/ui-kit";
2323
import { useSearchParams } from "react-router-dom";
2424
import { AlertConversation } from "@/api/generated";
2525
import { getMaliciousPackage } from "@/lib/utils";
26-
import { CardCodegateStatus } from "@/features/dashboard/components/card-codegate-status";
26+
import { CodegateStatus } from "@/features/dashboard-codegate-status/components/codegate-status";
2727
import { Search } from "lucide-react";
2828
import {
2929
useAlertsData,
@@ -132,7 +132,7 @@ export function Dashboard() {
132132
return (
133133
<div className="flex-col">
134134
<div className="grid 2xl:grid-cols-4 sm:grid-cols-2 grid-cols-1 items-stretch gap-4 w-full">
135-
<CardCodegateStatus />
135+
<CodegateStatus />
136136
<BarChart data={alerts} loading={isLoading} />
137137
<PieChart data={maliciousPackages} loading={isLoading} />
138138
<LineChart data={alerts} loading={isLoading} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { server } from "@/mocks/msw/node";
2+
import { http, HttpResponse } from "msw";
3+
import { expect } from "vitest";
4+
import { CodegateStatus } from "../codegate-status";
5+
import { render, waitFor } from "@/lib/test-utils";
6+
7+
const renderComponent = () => render(<CodegateStatus />);
8+
9+
describe("CardCodegateStatus", () => {
10+
test("renders 'healthy' state", async () => {
11+
server.use(
12+
http.get("*/health", () => HttpResponse.json({ status: "healthy" })),
13+
);
14+
15+
const { getByText } = renderComponent();
16+
17+
await waitFor(
18+
() => {
19+
expect(getByText(/healthy/i)).toBeVisible();
20+
},
21+
{ timeout: 10_000 },
22+
);
23+
});
24+
25+
test("renders 'unhealthy' state", async () => {
26+
server.use(http.get("*/health", () => HttpResponse.json({ status: null })));
27+
28+
const { getByText } = renderComponent();
29+
30+
await waitFor(
31+
() => {
32+
expect(getByText(/unhealthy/i)).toBeVisible();
33+
},
34+
{ timeout: 10_000 },
35+
);
36+
});
37+
38+
test("renders 'error' state when health check request fails", async () => {
39+
server.use(http.get("*/health", () => HttpResponse.error()));
40+
41+
const { getByText } = renderComponent();
42+
43+
await waitFor(
44+
() => {
45+
expect(getByText(/an error occurred/i)).toBeVisible();
46+
},
47+
{ timeout: 10_000 },
48+
);
49+
});
50+
51+
test("renders 'error' state when version check request fails", async () => {
52+
server.use(http.get("*/dashboard/version", () => HttpResponse.error()));
53+
54+
const { getByText } = renderComponent();
55+
56+
await waitFor(
57+
() => {
58+
expect(getByText(/an error occurred/i)).toBeVisible();
59+
},
60+
{ timeout: 10_000 },
61+
);
62+
});
63+
64+
test("renders 'latest version' state", async () => {
65+
server.use(
66+
http.get("*/dashboard/version", () =>
67+
HttpResponse.json({
68+
current_version: "foo",
69+
latest_version: "foo",
70+
is_latest: true,
71+
error: null,
72+
}),
73+
),
74+
);
75+
76+
const { getByText } = renderComponent();
77+
78+
await waitFor(
79+
() => {
80+
expect(getByText(/latest/i)).toBeVisible();
81+
},
82+
{ timeout: 10_000 },
83+
);
84+
});
85+
86+
test("renders 'update available' state", async () => {
87+
server.use(
88+
http.get("*/dashboard/version", () =>
89+
HttpResponse.json({
90+
current_version: "foo",
91+
latest_version: "bar",
92+
is_latest: false,
93+
error: null,
94+
}),
95+
),
96+
);
97+
98+
const { getByRole } = renderComponent();
99+
100+
await waitFor(
101+
() => {
102+
const role = getByRole("link", { name: /update available/i });
103+
expect(role).toBeVisible();
104+
expect(role).toHaveAttribute(
105+
"href",
106+
"https://docs.codegate.ai/how-to/install#upgrade-codegate",
107+
);
108+
},
109+
{ timeout: 10_000 },
110+
);
111+
});
112+
113+
test("renders 'version check error' state", async () => {
114+
server.use(
115+
http.get("*/dashboard/version", () =>
116+
HttpResponse.json({
117+
current_version: "foo",
118+
latest_version: "bar",
119+
is_latest: false,
120+
error: "foo",
121+
}),
122+
),
123+
);
124+
125+
const { getByText } = renderComponent();
126+
127+
await waitFor(
128+
() => {
129+
expect(getByText(/error checking version/i)).toBeVisible();
130+
},
131+
{ timeout: 10_000 },
132+
);
133+
});
134+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { XCircle } from "lucide-react";
2+
3+
export function CodegateStatusErrorUI() {
4+
return (
5+
<div className="flex flex-col items-center justify-center py-8">
6+
<XCircle className="text-red-600 mb-2 size-8" />
7+
<div className="text-base font-semibold text-secondary text-center">
8+
An error occurred
9+
</div>
10+
<div className="text-sm text-secondary text-center text-balance">
11+
If this issue persists, please reach out to us on{" "}
12+
<a
13+
className="underline text-secondary"
14+
href="https://discord.gg/stacklok"
15+
rel="noopener noreferrer"
16+
target="_blank"
17+
>
18+
Discord
19+
</a>{" "}
20+
or open a new{" "}
21+
<a
22+
className="underline text-secondary"
23+
href="https://github.com/stacklok/codegate/issues/new"
24+
rel="noopener noreferrer"
25+
target="_blank"
26+
>
27+
Github issue
28+
</a>
29+
</div>
30+
</div>
31+
);
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { LoaderCircle, CheckCircle2, XCircle } from "lucide-react";
2+
import { HealthStatus } from "../lib/get-codegate-health";
3+
4+
export const CodegateStatusHealth = ({
5+
data: data,
6+
isPending,
7+
}: {
8+
data: HealthStatus | null;
9+
isPending: boolean;
10+
}) => {
11+
if (isPending || data === null) {
12+
return (
13+
<div className="flex gap-2 items-center text-secondary justify-end overflow-hidden">
14+
Checking <LoaderCircle className="size-4 shrink-0 animate-spin" />
15+
</div>
16+
);
17+
}
18+
19+
switch (data) {
20+
case HealthStatus.HEALTHY:
21+
return (
22+
<div className="flex gap-2 items-center text-primary justify-end">
23+
{HealthStatus.HEALTHY} <CheckCircle2 className="size-4 shrink-0" />
24+
</div>
25+
);
26+
case HealthStatus.UNHEALTHY:
27+
return (
28+
<div className="flex gap-2 items-center text-primary justify-end overflow-hidden">
29+
{HealthStatus.UNHEALTHY} <XCircle className="size-4 shrink-0" />
30+
</div>
31+
);
32+
default: {
33+
data satisfies never;
34+
}
35+
}
36+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Dispatch, SetStateAction } from "react";
2+
import {
3+
Label,
4+
Select,
5+
SelectButton,
6+
TDropdownItemOrSection,
7+
} from "@stacklok/ui-kit";
8+
9+
// NOTE: We don't poll more than once per minute, as the server depends on
10+
// Github's public API, which is rate limited to 60reqs per hour.
11+
export const POLLING_INTERVAl = {
12+
"1_MIN": { value: 60_000, name: "1 minute" },
13+
"5_MIN": { value: 300_000, name: "5 minutes" },
14+
"10_MIN": { value: 600_000, name: "10 minutes" },
15+
} as const;
16+
17+
export const INTERVAL_SELECT_ITEMS: TDropdownItemOrSection[] = Object.entries(
18+
POLLING_INTERVAl,
19+
).map(([key, { name }]) => {
20+
return { textValue: name, id: key };
21+
});
22+
23+
export const DEFAULT_INTERVAL: PollingInterval = "5_MIN";
24+
25+
export type PollingInterval = keyof typeof POLLING_INTERVAl;
26+
27+
export function PollIntervalControl({
28+
className,
29+
pollingInterval,
30+
setPollingInterval,
31+
}: {
32+
className?: string;
33+
pollingInterval: PollingInterval;
34+
setPollingInterval: Dispatch<SetStateAction<PollingInterval>>;
35+
}) {
36+
return (
37+
<Select
38+
className={className}
39+
onSelectionChange={(v) =>
40+
setPollingInterval(v.toString() as PollingInterval)
41+
}
42+
items={INTERVAL_SELECT_ITEMS}
43+
defaultSelectedKey={pollingInterval}
44+
>
45+
<Label className="w-full text-right font-semibold text-secondary -mb-1">
46+
Check for updates
47+
</Label>
48+
<SelectButton
49+
isBorderless
50+
className="h-7 max-w-36 pr-0 [&>span>span]:text-right [&>span>span]:justify-end !gap-0 text-secondary"
51+
/>
52+
</Select>
53+
);
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useQueryClient } from "@tanstack/react-query";
2+
import { PollingInterval } from "./codegate-status-polling-control";
3+
import { getQueryOptionsCodeGateStatus } from "../hooks/use-codegate-status";
4+
import { useCallback, useEffect, useState } from "react";
5+
import { Button } from "@stacklok/ui-kit";
6+
import { RefreshCcw } from "lucide-react";
7+
import { twMerge } from "tailwind-merge";
8+
9+
export function CodeGateStatusRefreshButton({
10+
pollingInterval,
11+
className,
12+
}: {
13+
pollingInterval: PollingInterval;
14+
className?: string;
15+
}) {
16+
const queryClient = useQueryClient();
17+
const { queryKey } = getQueryOptionsCodeGateStatus(pollingInterval);
18+
19+
const [refreshed, setRefreshed] = useState<boolean>(false);
20+
21+
useEffect(() => {
22+
const id = setTimeout(() => setRefreshed(false), 500);
23+
return () => clearTimeout(id);
24+
}, [refreshed]);
25+
26+
const handleRefresh = useCallback(() => {
27+
setRefreshed(true);
28+
return queryClient.invalidateQueries({ queryKey, refetchType: "all" });
29+
}, [queryClient, queryKey]);
30+
31+
return (
32+
<Button
33+
onPress={handleRefresh}
34+
variant="tertiary"
35+
className={twMerge("size-7", className)}
36+
isDisabled={refreshed}
37+
>
38+
<RefreshCcw className={refreshed ? "animate-spin-once" : undefined} />
39+
</Button>
40+
);
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { LoaderCircle, CheckCircle2, CircleAlert, XCircle } from "lucide-react";
2+
import { VersionResponse } from "../lib/get-version-status";
3+
import { Link, Tooltip, TooltipTrigger } from "@stacklok/ui-kit";
4+
5+
export const CodegateStatusVersion = ({
6+
data,
7+
isPending,
8+
}: {
9+
data: VersionResponse | null;
10+
isPending: boolean;
11+
}) => {
12+
if (isPending || data === null) {
13+
return (
14+
<div className="flex gap-2 items-center text-secondary justify-end overflow-hidden">
15+
Checking <LoaderCircle className="size-4 shrink-0 animate-spin" />
16+
</div>
17+
);
18+
}
19+
20+
const { current_version, is_latest, latest_version, error } = data || {};
21+
22+
if (error !== null || is_latest === null) {
23+
return (
24+
<div className="flex gap-2 items-center text-primary justify-end overflow-hidden">
25+
Error checking version <XCircle className="size-4 shrink-0" />
26+
</div>
27+
);
28+
}
29+
30+
switch (is_latest) {
31+
case true:
32+
return (
33+
<div className="flex gap-2 items-center text-primary justify-end">
34+
Latest <CheckCircle2 className="size-4 shrink-0" />
35+
</div>
36+
);
37+
case false:
38+
return (
39+
<div>
40+
<TooltipTrigger delay={0}>
41+
<Link
42+
className="flex gap-2 items-center text-primary justify-end overflow-hidden"
43+
variant="secondary"
44+
target="_blank"
45+
rel="noopener noreferrer"
46+
href="https://docs.codegate.ai/how-to/install#upgrade-codegate"
47+
>
48+
Update available <CircleAlert className="size-4 shrink-0" />
49+
</Link>
50+
<Tooltip className="text-right">
51+
<span className="block">Current version: {current_version}</span>
52+
<span className="block">Latest version: {latest_version}</span>
53+
</Tooltip>
54+
</TooltipTrigger>
55+
</div>
56+
);
57+
default: {
58+
is_latest satisfies never;
59+
}
60+
}
61+
};

0 commit comments

Comments
 (0)