-
Notifications
You must be signed in to change notification settings - Fork 888
feat(site): add new filter to audit logs #7878
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
Changes from all commits
bf39e76
5c47bdf
529ef8c
de52a02
de435b1
48cac76
b86831f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import { useMe } from "hooks" | ||
import { BaseOption } from "./options" | ||
import { getUsers } from "api/api" | ||
import { UseFilterMenuOptions, useFilterMenu } from "./menu" | ||
import { FilterSearchMenu, OptionItem } from "./filter" | ||
import { UserAvatar } from "components/UserAvatar/UserAvatar" | ||
|
||
export type UserOption = BaseOption & { | ||
avatarUrl?: string | ||
} | ||
|
||
export const useUserFilterMenu = ({ | ||
value, | ||
onChange, | ||
enabled, | ||
}: Pick< | ||
UseFilterMenuOptions<UserOption>, | ||
"value" | "onChange" | "enabled" | ||
>) => { | ||
const me = useMe() | ||
|
||
const addMeAsFirstOption = (options: UserOption[]) => { | ||
options = options.filter((option) => option.value !== me.username) | ||
return [ | ||
{ label: me.username, value: me.username, avatarUrl: me.avatar_url }, | ||
...options, | ||
] | ||
} | ||
|
||
return useFilterMenu({ | ||
onChange, | ||
enabled, | ||
value, | ||
id: "owner", | ||
getSelectedOption: async () => { | ||
const usersRes = await getUsers({ q: value, limit: 1 }) | ||
const firstUser = usersRes.users.at(0) | ||
if (firstUser && firstUser.username === value) { | ||
return { | ||
label: firstUser.username, | ||
value: firstUser.username, | ||
avatarUrl: firstUser.avatar_url, | ||
} | ||
} | ||
return null | ||
}, | ||
getOptions: async (query) => { | ||
const usersRes = await getUsers({ q: query, limit: 25 }) | ||
let options: UserOption[] = usersRes.users.map((user) => ({ | ||
label: user.username, | ||
value: user.username, | ||
avatarUrl: user.avatar_url, | ||
})) | ||
options = addMeAsFirstOption(options) | ||
return options | ||
}, | ||
}) | ||
} | ||
|
||
export type UserFilterMenu = ReturnType<typeof useUserFilterMenu> | ||
|
||
export const UserMenu = (menu: UserFilterMenu) => { | ||
return ( | ||
<FilterSearchMenu | ||
id="users-menu" | ||
menu={menu} | ||
label={ | ||
menu.selectedOption ? ( | ||
<UserOptionItem option={menu.selectedOption} /> | ||
) : ( | ||
"All users" | ||
) | ||
} | ||
> | ||
{(itemProps) => <UserOptionItem {...itemProps} />} | ||
</FilterSearchMenu> | ||
) | ||
} | ||
|
||
const UserOptionItem = ({ | ||
option, | ||
isSelected, | ||
}: { | ||
option: UserOption | ||
isSelected?: boolean | ||
}) => { | ||
return ( | ||
<OptionItem | ||
option={option} | ||
isSelected={isSelected} | ||
left={ | ||
<UserAvatar | ||
username={option.label} | ||
avatarURL={option.avatarUrl} | ||
sx={{ width: 16, height: 16, fontSize: 8 }} | ||
/> | ||
} | ||
/> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
import { AuditActions, ResourceTypes } from "api/typesGenerated" | ||
import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter" | ||
import { | ||
Filter, | ||
FilterMenu, | ||
MenuSkeleton, | ||
OptionItem, | ||
SearchFieldSkeleton, | ||
useFilter, | ||
} from "components/Filter/filter" | ||
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu" | ||
import { BaseOption } from "components/Filter/options" | ||
import capitalize from "lodash/capitalize" | ||
|
||
const PRESET_FILTERS = [ | ||
{ | ||
query: "resource_type:workspace action:create", | ||
name: "Created workspaces", | ||
}, | ||
{ query: "resource_type:template action:create", name: "Added templates" }, | ||
{ query: "resource_type:user action:delete", name: "Deleted users" }, | ||
{ | ||
query: "resource_type:workspace_build action:start build_reason:initiator", | ||
name: "Builds started by a user", | ||
}, | ||
{ | ||
query: "resource_type:api_key action:login", | ||
name: "User logins", | ||
}, | ||
] | ||
|
||
export const AuditFilter = ({ | ||
filter, | ||
error, | ||
menus, | ||
}: { | ||
filter: ReturnType<typeof useFilter> | ||
error?: unknown | ||
menus: { | ||
user: UserFilterMenu | ||
action: ActionFilterMenu | ||
resourceType: ResourceTypeFilterMenu | ||
} | ||
}) => { | ||
return ( | ||
<Filter | ||
learnMoreLink="https://coder.com/docs/v2/latest/admin/audit-logs#filtering-logs" | ||
presets={PRESET_FILTERS} | ||
isLoading={menus.user.isInitializing} | ||
filter={filter} | ||
error={error} | ||
options={ | ||
<> | ||
<ResourceTypeMenu {...menus.resourceType} /> | ||
<ActionMenu {...menus.action} /> | ||
<UserMenu {...menus.user} /> | ||
</> | ||
} | ||
skeleton={ | ||
<> | ||
<SearchFieldSkeleton /> | ||
<MenuSkeleton /> | ||
<MenuSkeleton /> | ||
<MenuSkeleton /> | ||
</> | ||
} | ||
/> | ||
) | ||
} | ||
|
||
export const useActionFilterMenu = ({ | ||
value, | ||
onChange, | ||
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => { | ||
const actionOptions: BaseOption[] = AuditActions.map((action) => ({ | ||
value: action, | ||
label: capitalize(action), | ||
})) | ||
return useFilterMenu({ | ||
onChange, | ||
value, | ||
id: "status", | ||
getSelectedOption: async () => | ||
actionOptions.find((option) => option.value === value) ?? null, | ||
getOptions: async () => actionOptions, | ||
}) | ||
} | ||
|
||
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu> | ||
|
||
const ActionMenu = (menu: ActionFilterMenu) => { | ||
return ( | ||
<FilterMenu | ||
id="action-menu" | ||
menu={menu} | ||
label={ | ||
menu.selectedOption ? ( | ||
<OptionItem option={menu.selectedOption} /> | ||
) : ( | ||
"All actions" | ||
) | ||
} | ||
> | ||
{(itemProps) => <OptionItem {...itemProps} />} | ||
</FilterMenu> | ||
) | ||
} | ||
|
||
export const useResourceTypeFilterMenu = ({ | ||
value, | ||
onChange, | ||
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => { | ||
const actionOptions: BaseOption[] = ResourceTypes.map((type) => { | ||
let label = capitalize(type) | ||
|
||
if (type === "api_key") { | ||
label = "API Key" | ||
} | ||
|
||
if (type === "git_ssh_key") { | ||
label = "Git SSH Key" | ||
} | ||
|
||
if (type === "template_version") { | ||
label = "Template Version" | ||
} | ||
|
||
if (type === "workspace_build") { | ||
label = "Workspace Build" | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is fine! We could probably shorten with a map if we wanted to, but not a blocker. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I just went to what Copilot suggested to me 😆 since this is not complex code I think it is ok to let to refactor this if we need to extract it into a function to be reused anywhere else. |
||
|
||
return { | ||
value: type, | ||
label, | ||
} | ||
}) | ||
return useFilterMenu({ | ||
onChange, | ||
value, | ||
id: "resourceType", | ||
getSelectedOption: async () => | ||
actionOptions.find((option) => option.value === value) ?? null, | ||
getOptions: async () => actionOptions, | ||
}) | ||
} | ||
|
||
export type ResourceTypeFilterMenu = ReturnType< | ||
typeof useResourceTypeFilterMenu | ||
> | ||
|
||
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => { | ||
return ( | ||
<FilterMenu | ||
id="resource-type-menu" | ||
menu={menu} | ||
label={ | ||
menu.selectedOption ? ( | ||
<OptionItem option={menu.selectedOption} /> | ||
) : ( | ||
"All resource types" | ||
) | ||
} | ||
> | ||
{(itemProps) => <OptionItem {...itemProps} />} | ||
</FilterMenu> | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We call
getUsers
here, and also on line 48. Curious why we can't just make one call to grab all users and then filter on the user we want forgetSelectedOption
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question, let's say we have a user called Wesley, Wesley is probably one of the last users to be queried in the database, so when we display the autocomplete options for the users, maybe Wesley is not shown because we only show the first 25 results. So making a separate query to get the selected value, in this case Wesley, is safer and we can be sure we are going to find it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha, thanks for explaining!