Skip to content

Commit a38e2cd

Browse files
authored
feat: add pagination for alerts table (#104)
* refactor: extract <AlertsTable /> * limit the number of items displayed on one page * allow pagination * implement page slicing * fully implement pagination * remove unnecessary debug print * extract hook for client side pagination * improve pagination layout * fixes for pagination logic
1 parent 8ba7d36 commit a38e2cd

File tree

9 files changed

+418
-261
lines changed

9 files changed

+418
-261
lines changed

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
},
4646
"devDependencies": {
4747
"@eslint/js": "^9.15.0",
48+
"@faker-js/faker": "^9.4.0",
4849
"@hey-api/openapi-ts": "^0.61.2",
4950
"@tailwindcss/typography": "^0.5.16",
5051
"@testing-library/jest-dom": "^6.6.3",

src/components/AlertsTable.tsx

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { format } from "date-fns";
2+
import {
3+
Cell,
4+
Column,
5+
Input,
6+
Row,
7+
SearchField,
8+
Table,
9+
TableBody,
10+
FieldGroup,
11+
TableHeader,
12+
SearchFieldClearButton,
13+
Badge,
14+
Button,
15+
} from "@stacklok/ui-kit";
16+
import { Switch } from "@stacklok/ui-kit";
17+
import { AlertConversation } from "@/api/generated";
18+
import { Tooltip, TooltipTrigger } from "@stacklok/ui-kit";
19+
import { getMaliciousPackage } from "@/lib/utils";
20+
import { Search } from "lucide-react";
21+
import { Markdown } from "./Markdown";
22+
import { useAlertSearch } from "@/hooks/useAlertSearch";
23+
import { useCallback } from "react";
24+
import { useSearchParams } from "react-router-dom";
25+
import { useFilteredAlerts } from "@/hooks/useAlertsData";
26+
import { useClientSidePagination } from "@/hooks/useClientSidePagination";
27+
28+
const wrapObjectOutput = (input: AlertConversation["trigger_string"]) => {
29+
const data = getMaliciousPackage(input);
30+
if (data === null) return "N/A";
31+
if (typeof data === "string") {
32+
return (
33+
<div className="bg-gray-25 rounded-lg overflow-auto p-4">
34+
<Markdown>{data}</Markdown>
35+
</div>
36+
);
37+
}
38+
if (!data.type || !data.name) return "N/A";
39+
40+
return (
41+
<div className="max-h-40 w-fit overflow-y-auto whitespace-pre-wrap p-2">
42+
<label className="font-medium">Package:</label>
43+
&nbsp;
44+
<a
45+
href={`https://www.insight.stacklok.com/report/${data.type}/${data.name}`}
46+
target="_blank"
47+
rel="noopener noreferrer"
48+
className="text-brand-500 hover:underline"
49+
>
50+
{data.type}/{data.name}
51+
</a>
52+
{data.status && (
53+
<>
54+
<br />
55+
<label className="font-medium">Status:</label> {data.status}
56+
</>
57+
)}
58+
{data.description && (
59+
<>
60+
<br />
61+
<label className="font-medium">Description:</label> {data.description}
62+
</>
63+
)}
64+
</div>
65+
);
66+
};
67+
68+
export function AlertsTable() {
69+
const {
70+
isMaliciousFilterActive,
71+
setIsMaliciousFilterActive,
72+
setSearch,
73+
search,
74+
page,
75+
nextPage,
76+
prevPage,
77+
} = useAlertSearch();
78+
const [searchParams, setSearchParams] = useSearchParams();
79+
const { data: filteredAlerts = [] } = useFilteredAlerts();
80+
81+
const { dataView, hasNextPage, hasPreviousPage } = useClientSidePagination(
82+
filteredAlerts,
83+
page,
84+
15,
85+
);
86+
87+
const handleToggleFilter = useCallback(
88+
(isChecked: boolean) => {
89+
if (isChecked) {
90+
searchParams.set("maliciousPkg", "true");
91+
searchParams.delete("search");
92+
setSearch("");
93+
} else {
94+
searchParams.delete("maliciousPkg");
95+
}
96+
setSearchParams(searchParams);
97+
setIsMaliciousFilterActive(isChecked);
98+
},
99+
[setSearchParams, setSearch, searchParams, setIsMaliciousFilterActive],
100+
);
101+
102+
const handleSearch = useCallback(
103+
(value: string) => {
104+
if (value) {
105+
searchParams.set("search", value);
106+
searchParams.delete("maliciousPkg");
107+
setSearch(value);
108+
setIsMaliciousFilterActive(false);
109+
} else {
110+
searchParams.delete("search");
111+
setSearch("");
112+
}
113+
setSearchParams(searchParams);
114+
},
115+
[searchParams, setIsMaliciousFilterActive, setSearch, setSearchParams],
116+
);
117+
118+
return (
119+
<>
120+
<div className="flex mb-2 mx-2 justify-between w-[calc(100vw-20rem)]">
121+
<div className="flex gap-2 items-center">
122+
<h2 className="font-bold text-lg">All Alerts</h2>
123+
<Badge size="sm" variant="inverted" data-testid="alerts-count">
124+
{filteredAlerts.length}
125+
</Badge>
126+
</div>
127+
128+
<div className="flex items-center gap-8">
129+
<div className="flex items-center space-x-2">
130+
<TooltipTrigger>
131+
<Switch
132+
id="malicious-packages"
133+
isSelected={isMaliciousFilterActive}
134+
onChange={handleToggleFilter}
135+
>
136+
Malicious Packages
137+
</Switch>
138+
139+
<Tooltip>
140+
<p>Filter by malicious packages</p>
141+
</Tooltip>
142+
</TooltipTrigger>
143+
</div>
144+
<SearchField
145+
type="text"
146+
aria-label="Search alerts"
147+
value={search}
148+
onChange={(value) => handleSearch(value.toLowerCase().trim())}
149+
>
150+
<FieldGroup>
151+
<Input
152+
type="search"
153+
placeholder="Search..."
154+
isBorderless
155+
icon={<Search />}
156+
/>
157+
<SearchFieldClearButton />
158+
</FieldGroup>
159+
</SearchField>
160+
</div>
161+
</div>
162+
<div className="overflow-x-auto">
163+
<Table data-testid="alerts-table" aria-label="Alerts table">
164+
<TableHeader>
165+
<Row>
166+
<Column isRowHeader width={150}>
167+
Trigger Type
168+
</Column>
169+
<Column width={300}>Trigger Token</Column>
170+
<Column width={150}>File</Column>
171+
<Column width={250}>Code</Column>
172+
<Column width={100}>Timestamp</Column>
173+
</Row>
174+
</TableHeader>
175+
<TableBody>
176+
{dataView
177+
.sort(
178+
(a, b) =>
179+
new Date(b.timestamp).getTime() -
180+
new Date(a.timestamp).getTime(),
181+
)
182+
.map((alert) => (
183+
<Row key={alert.alert_id} className="h-20">
184+
<Cell className="truncate">{alert.trigger_type}</Cell>
185+
<Cell className="overflow-auto whitespace-nowrap max-w-80">
186+
{wrapObjectOutput(alert.trigger_string)}
187+
</Cell>
188+
<Cell className="truncate">
189+
{alert.code_snippet?.filepath || "N/A"}
190+
</Cell>
191+
<Cell className="overflow-auto whitespace-nowrap max-w-80">
192+
{alert.code_snippet?.code ? (
193+
<pre className="max-h-40 overflow-auto bg-gray-100 p-2 whitespace-pre-wrap">
194+
<code>{alert.code_snippet.code}</code>
195+
</pre>
196+
) : (
197+
"N/A"
198+
)}
199+
</Cell>
200+
<Cell className="truncate">
201+
<div data-testid="date">
202+
{format(new Date(alert.timestamp ?? ""), "y/MM/dd")}
203+
</div>
204+
<div data-testid="time">
205+
{format(new Date(alert.timestamp ?? ""), "hh:mm:ss a")}
206+
</div>
207+
</Cell>
208+
</Row>
209+
))}
210+
</TableBody>
211+
</Table>
212+
</div>
213+
214+
<div className="flex justify-center w-full p-4">
215+
<div className="flex gap-2">
216+
<Button isDisabled={!hasPreviousPage} onPress={prevPage}>
217+
Previous
218+
</Button>
219+
<Button isDisabled={!hasNextPage} onPress={nextPage}>
220+
Next
221+
</Button>
222+
</div>
223+
</div>
224+
</>
225+
);
226+
}

0 commit comments

Comments
 (0)