Skip to content

Commit d793564

Browse files
feat(site): add new filter to audit logs (#7878)
1 parent ab3a649 commit d793564

File tree

13 files changed

+460
-337
lines changed

13 files changed

+460
-337
lines changed
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useMe } from "hooks"
2+
import { BaseOption } from "./options"
3+
import { getUsers } from "api/api"
4+
import { UseFilterMenuOptions, useFilterMenu } from "./menu"
5+
import { FilterSearchMenu, OptionItem } from "./filter"
6+
import { UserAvatar } from "components/UserAvatar/UserAvatar"
7+
8+
export type UserOption = BaseOption & {
9+
avatarUrl?: string
10+
}
11+
12+
export const useUserFilterMenu = ({
13+
value,
14+
onChange,
15+
enabled,
16+
}: Pick<
17+
UseFilterMenuOptions<UserOption>,
18+
"value" | "onChange" | "enabled"
19+
>) => {
20+
const me = useMe()
21+
22+
const addMeAsFirstOption = (options: UserOption[]) => {
23+
options = options.filter((option) => option.value !== me.username)
24+
return [
25+
{ label: me.username, value: me.username, avatarUrl: me.avatar_url },
26+
...options,
27+
]
28+
}
29+
30+
return useFilterMenu({
31+
onChange,
32+
enabled,
33+
value,
34+
id: "owner",
35+
getSelectedOption: async () => {
36+
const usersRes = await getUsers({ q: value, limit: 1 })
37+
const firstUser = usersRes.users.at(0)
38+
if (firstUser && firstUser.username === value) {
39+
return {
40+
label: firstUser.username,
41+
value: firstUser.username,
42+
avatarUrl: firstUser.avatar_url,
43+
}
44+
}
45+
return null
46+
},
47+
getOptions: async (query) => {
48+
const usersRes = await getUsers({ q: query, limit: 25 })
49+
let options: UserOption[] = usersRes.users.map((user) => ({
50+
label: user.username,
51+
value: user.username,
52+
avatarUrl: user.avatar_url,
53+
}))
54+
options = addMeAsFirstOption(options)
55+
return options
56+
},
57+
})
58+
}
59+
60+
export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>
61+
62+
export const UserMenu = (menu: UserFilterMenu) => {
63+
return (
64+
<FilterSearchMenu
65+
id="users-menu"
66+
menu={menu}
67+
label={
68+
menu.selectedOption ? (
69+
<UserOptionItem option={menu.selectedOption} />
70+
) : (
71+
"All users"
72+
)
73+
}
74+
>
75+
{(itemProps) => <UserOptionItem {...itemProps} />}
76+
</FilterSearchMenu>
77+
)
78+
}
79+
80+
const UserOptionItem = ({
81+
option,
82+
isSelected,
83+
}: {
84+
option: UserOption
85+
isSelected?: boolean
86+
}) => {
87+
return (
88+
<OptionItem
89+
option={option}
90+
isSelected={isSelected}
91+
left={
92+
<UserAvatar
93+
username={option.label}
94+
avatarURL={option.avatarUrl}
95+
sx={{ width: 16, height: 16, fontSize: 8 }}
96+
/>
97+
}
98+
/>
99+
)
100+
}

site/src/components/Filter/filter.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export const Filter = ({
175175
}
176176
size="small"
177177
InputProps={{
178+
"aria-label": "Filter",
178179
name: "query",
179180
placeholder: "Search...",
180181
value: searchQuery,
+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { AuditActions, ResourceTypes } from "api/typesGenerated"
2+
import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter"
3+
import {
4+
Filter,
5+
FilterMenu,
6+
MenuSkeleton,
7+
OptionItem,
8+
SearchFieldSkeleton,
9+
useFilter,
10+
} from "components/Filter/filter"
11+
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
12+
import { BaseOption } from "components/Filter/options"
13+
import capitalize from "lodash/capitalize"
14+
15+
const PRESET_FILTERS = [
16+
{
17+
query: "resource_type:workspace action:create",
18+
name: "Created workspaces",
19+
},
20+
{ query: "resource_type:template action:create", name: "Added templates" },
21+
{ query: "resource_type:user action:delete", name: "Deleted users" },
22+
{
23+
query: "resource_type:workspace_build action:start build_reason:initiator",
24+
name: "Builds started by a user",
25+
},
26+
{
27+
query: "resource_type:api_key action:login",
28+
name: "User logins",
29+
},
30+
]
31+
32+
export const AuditFilter = ({
33+
filter,
34+
error,
35+
menus,
36+
}: {
37+
filter: ReturnType<typeof useFilter>
38+
error?: unknown
39+
menus: {
40+
user: UserFilterMenu
41+
action: ActionFilterMenu
42+
resourceType: ResourceTypeFilterMenu
43+
}
44+
}) => {
45+
return (
46+
<Filter
47+
learnMoreLink="https://coder.com/docs/v2/latest/admin/audit-logs#filtering-logs"
48+
presets={PRESET_FILTERS}
49+
isLoading={menus.user.isInitializing}
50+
filter={filter}
51+
error={error}
52+
options={
53+
<>
54+
<ResourceTypeMenu {...menus.resourceType} />
55+
<ActionMenu {...menus.action} />
56+
<UserMenu {...menus.user} />
57+
</>
58+
}
59+
skeleton={
60+
<>
61+
<SearchFieldSkeleton />
62+
<MenuSkeleton />
63+
<MenuSkeleton />
64+
<MenuSkeleton />
65+
</>
66+
}
67+
/>
68+
)
69+
}
70+
71+
export const useActionFilterMenu = ({
72+
value,
73+
onChange,
74+
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => {
75+
const actionOptions: BaseOption[] = AuditActions.map((action) => ({
76+
value: action,
77+
label: capitalize(action),
78+
}))
79+
return useFilterMenu({
80+
onChange,
81+
value,
82+
id: "status",
83+
getSelectedOption: async () =>
84+
actionOptions.find((option) => option.value === value) ?? null,
85+
getOptions: async () => actionOptions,
86+
})
87+
}
88+
89+
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>
90+
91+
const ActionMenu = (menu: ActionFilterMenu) => {
92+
return (
93+
<FilterMenu
94+
id="action-menu"
95+
menu={menu}
96+
label={
97+
menu.selectedOption ? (
98+
<OptionItem option={menu.selectedOption} />
99+
) : (
100+
"All actions"
101+
)
102+
}
103+
>
104+
{(itemProps) => <OptionItem {...itemProps} />}
105+
</FilterMenu>
106+
)
107+
}
108+
109+
export const useResourceTypeFilterMenu = ({
110+
value,
111+
onChange,
112+
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => {
113+
const actionOptions: BaseOption[] = ResourceTypes.map((type) => {
114+
let label = capitalize(type)
115+
116+
if (type === "api_key") {
117+
label = "API Key"
118+
}
119+
120+
if (type === "git_ssh_key") {
121+
label = "Git SSH Key"
122+
}
123+
124+
if (type === "template_version") {
125+
label = "Template Version"
126+
}
127+
128+
if (type === "workspace_build") {
129+
label = "Workspace Build"
130+
}
131+
132+
return {
133+
value: type,
134+
label,
135+
}
136+
})
137+
return useFilterMenu({
138+
onChange,
139+
value,
140+
id: "resourceType",
141+
getSelectedOption: async () =>
142+
actionOptions.find((option) => option.value === value) ?? null,
143+
getOptions: async () => actionOptions,
144+
})
145+
}
146+
147+
export type ResourceTypeFilterMenu = ReturnType<
148+
typeof useResourceTypeFilterMenu
149+
>
150+
151+
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
152+
return (
153+
<FilterMenu
154+
id="resource-type-menu"
155+
menu={menu}
156+
label={
157+
menu.selectedOption ? (
158+
<OptionItem option={menu.selectedOption} />
159+
) : (
160+
"All resource types"
161+
)
162+
}
163+
>
164+
{(itemProps) => <OptionItem {...itemProps} />}
165+
</FilterMenu>
166+
)
167+
}

site/src/pages/AuditPage/AuditPage.test.tsx

+3-14
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,6 @@ describe("AuditPage", () => {
6868
})
6969

7070
describe("Filtering", () => {
71-
it("filters by typing", async () => {
72-
await renderPage()
73-
await screen.findByText("updated", { exact: false })
74-
75-
const filterField = screen.getByLabelText("Filter")
76-
const query = "resource_type:workspace action:create"
77-
await userEvent.type(filterField, query)
78-
await screen.findByText("created", { exact: false })
79-
const editWorkspace = screen.queryByText("updated", { exact: false })
80-
expect(editWorkspace).not.toBeInTheDocument()
81-
})
82-
8371
it("filters by URL", async () => {
8472
const getAuditLogsSpy = jest
8573
.spyOn(API, "getAuditLogs")
@@ -88,13 +76,14 @@ describe("AuditPage", () => {
8876
const query = "resource_type:workspace action:create"
8977
await renderPage({ filter: query })
9078

91-
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
79+
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 1, q: query })
9280
})
9381

9482
it("resets page to 1 when filter is changed", async () => {
9583
await renderPage({ page: 2 })
9684

9785
const getAuditLogsSpy = jest.spyOn(API, "getAuditLogs")
86+
getAuditLogsSpy.mockClear()
9887

9988
const filterField = screen.getByLabelText("Filter")
10089
const query = "resource_type:workspace action:create"
@@ -103,7 +92,7 @@ describe("AuditPage", () => {
10392
await waitFor(() =>
10493
expect(getAuditLogsSpy).toBeCalledWith({
10594
limit: 25,
106-
offset: 0,
95+
offset: 1,
10796
q: query,
10897
}),
10998
)

0 commit comments

Comments
 (0)