mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(site): add new filter to audit logs (#7878)
This commit is contained in:
@@ -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 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export const Filter = ({
|
||||
}
|
||||
size="small"
|
||||
InputProps={{
|
||||
"aria-label": "Filter",
|
||||
name: "query",
|
||||
placeholder: "Search...",
|
||||
value: searchQuery,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -68,18 +68,6 @@ describe("AuditPage", () => {
|
||||
})
|
||||
|
||||
describe("Filtering", () => {
|
||||
it("filters by typing", async () => {
|
||||
await renderPage()
|
||||
await screen.findByText("updated", { exact: false })
|
||||
|
||||
const filterField = screen.getByLabelText("Filter")
|
||||
const query = "resource_type:workspace action:create"
|
||||
await userEvent.type(filterField, query)
|
||||
await screen.findByText("created", { exact: false })
|
||||
const editWorkspace = screen.queryByText("updated", { exact: false })
|
||||
expect(editWorkspace).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("filters by URL", async () => {
|
||||
const getAuditLogsSpy = jest
|
||||
.spyOn(API, "getAuditLogs")
|
||||
@@ -88,13 +76,14 @@ describe("AuditPage", () => {
|
||||
const query = "resource_type:workspace action:create"
|
||||
await renderPage({ filter: query })
|
||||
|
||||
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
|
||||
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 1, q: query })
|
||||
})
|
||||
|
||||
it("resets page to 1 when filter is changed", async () => {
|
||||
await renderPage({ page: 2 })
|
||||
|
||||
const getAuditLogsSpy = jest.spyOn(API, "getAuditLogs")
|
||||
getAuditLogsSpy.mockClear()
|
||||
|
||||
const filterField = screen.getByLabelText("Filter")
|
||||
const query = "resource_type:workspace action:create"
|
||||
@@ -103,7 +92,7 @@ describe("AuditPage", () => {
|
||||
await waitFor(() =>
|
||||
expect(getAuditLogsSpy).toBeCalledWith({
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
offset: 1,
|
||||
q: query,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
import { useMachine } from "@xstate/react"
|
||||
import {
|
||||
getPaginationContext,
|
||||
nonInitialPage,
|
||||
} from "components/PaginationWidget/utils"
|
||||
import { nonInitialPage } from "components/PaginationWidget/utils"
|
||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { pageTitle } from "utils/page"
|
||||
import { auditMachine } from "xServices/audit/auditXService"
|
||||
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
|
||||
import { AuditPageView } from "./AuditPageView"
|
||||
import { useUserFilterMenu } from "components/Filter/UserFilter"
|
||||
import { useFilter } from "components/Filter/filter"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { usePagination } from "hooks"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAuditLogs } from "api/api"
|
||||
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter"
|
||||
|
||||
const AuditPage: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const filter = searchParams.get("filter") ?? ""
|
||||
const [auditState, auditSend] = useMachine(auditMachine, {
|
||||
context: {
|
||||
filter,
|
||||
paginationContext: getPaginationContext(searchParams),
|
||||
},
|
||||
actions: {
|
||||
updateURL: (context, event) =>
|
||||
setSearchParams({ page: event.page, filter: context.filter }),
|
||||
const dashboard = useDashboard()
|
||||
const searchParamsResult = useSearchParams()
|
||||
const pagination = usePagination({ searchParamsResult })
|
||||
const filter = useFilter({
|
||||
searchParamsResult,
|
||||
onUpdate: () => {
|
||||
pagination.goToPage(1)
|
||||
},
|
||||
})
|
||||
|
||||
const { auditLogs, count, apiError } = auditState.context
|
||||
const paginationRef = auditState.context.paginationRef as PaginationMachineRef
|
||||
const userMenu = useUserFilterMenu({
|
||||
value: filter.values.username,
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
username: option?.value,
|
||||
}),
|
||||
})
|
||||
const actionMenu = useActionFilterMenu({
|
||||
value: filter.values.action,
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
action: option?.value,
|
||||
}),
|
||||
})
|
||||
const resourceTypeMenu = useResourceTypeFilterMenu({
|
||||
value: filter.values["resource_type"],
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
resource_type: option?.value,
|
||||
}),
|
||||
})
|
||||
const { audit_log: isAuditLogVisible } = useFeatureVisibility()
|
||||
const { data, error } = useQuery({
|
||||
queryKey: ["auditLogs", filter.query, pagination.page],
|
||||
queryFn: () => {
|
||||
return getAuditLogs({
|
||||
offset: pagination.page,
|
||||
limit: 25,
|
||||
q: filter.query,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,16 +65,29 @@ const AuditPage: FC = () => {
|
||||
<title>{pageTitle("Audit")}</title>
|
||||
</Helmet>
|
||||
<AuditPageView
|
||||
filter={filter}
|
||||
auditLogs={auditLogs}
|
||||
count={count}
|
||||
onFilter={(filter) => {
|
||||
auditSend("FILTER", { filter })
|
||||
}}
|
||||
paginationRef={paginationRef}
|
||||
isNonInitialPage={nonInitialPage(searchParams)}
|
||||
auditLogs={data?.audit_logs}
|
||||
count={data?.count}
|
||||
page={pagination.page}
|
||||
limit={pagination.limit}
|
||||
onPageChange={pagination.goToPage}
|
||||
isNonInitialPage={nonInitialPage(searchParamsResult[0])}
|
||||
isAuditLogVisible={isAuditLogVisible}
|
||||
error={apiError}
|
||||
error={error}
|
||||
filterProps={
|
||||
dashboard.experiments.includes("workspace_filter")
|
||||
? {
|
||||
filter,
|
||||
menus: {
|
||||
user: userMenu,
|
||||
action: actionMenu,
|
||||
resourceType: resourceTypeMenu,
|
||||
},
|
||||
}
|
||||
: {
|
||||
filter: filter.query,
|
||||
onFilter: filter.update,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,51 +1,91 @@
|
||||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import { createPaginationRef } from "components/PaginationWidget/utils"
|
||||
import { MockAuditLog, MockAuditLog2 } from "testHelpers/entities"
|
||||
import { AuditPageView, AuditPageViewProps } from "./AuditPageView"
|
||||
/* eslint-disable eslint-comments/disable-enable-pair -- ignore */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- We don't care about any here */
|
||||
import { Meta, StoryObj } from "@storybook/react"
|
||||
import { MockAuditLog, MockAuditLog2, MockUser } from "testHelpers/entities"
|
||||
import { AuditPageView } from "./AuditPageView"
|
||||
import { action } from "@storybook/addon-actions"
|
||||
import { WorkspacesPageView } from "pages/WorkspacesPage/WorkspacesPageView"
|
||||
import { ComponentProps } from "react"
|
||||
|
||||
export default {
|
||||
const mockMenu = {
|
||||
initialOption: undefined,
|
||||
isInitializing: false,
|
||||
isSearching: false,
|
||||
query: "",
|
||||
searchOptions: [],
|
||||
selectedOption: undefined,
|
||||
selectOption: action("selectOption"),
|
||||
setQuery: action("updateQuery"),
|
||||
}
|
||||
|
||||
const defaultFilterProps = {
|
||||
filter: {
|
||||
query: `owner:me`,
|
||||
update: () => action("update"),
|
||||
debounceUpdate: action("debounce") as any,
|
||||
used: false,
|
||||
values: {
|
||||
username: MockUser.username,
|
||||
action: undefined,
|
||||
resource_type: undefined,
|
||||
},
|
||||
},
|
||||
menus: {
|
||||
user: mockMenu,
|
||||
action: mockMenu,
|
||||
resourceType: mockMenu,
|
||||
},
|
||||
} as ComponentProps<typeof AuditPageView>["filterProps"]
|
||||
|
||||
const meta: Meta<typeof AuditPageView> = {
|
||||
title: "pages/AuditPageView",
|
||||
component: AuditPageView,
|
||||
args: {
|
||||
auditLogs: [MockAuditLog, MockAuditLog2],
|
||||
count: 1000,
|
||||
paginationRef: createPaginationRef({ page: 1, limit: 25 }),
|
||||
page: 1,
|
||||
limit: 25,
|
||||
isAuditLogVisible: true,
|
||||
filterProps: defaultFilterProps,
|
||||
},
|
||||
} as ComponentMeta<typeof AuditPageView>
|
||||
|
||||
const Template: Story<AuditPageViewProps> = (args) => (
|
||||
<AuditPageView {...args} />
|
||||
)
|
||||
|
||||
export const AuditPage = Template.bind({})
|
||||
|
||||
export const Loading = Template.bind({})
|
||||
Loading.args = {
|
||||
auditLogs: undefined,
|
||||
count: undefined,
|
||||
isNonInitialPage: false,
|
||||
}
|
||||
|
||||
export const EmptyPage = Template.bind({})
|
||||
EmptyPage.args = {
|
||||
auditLogs: [],
|
||||
isNonInitialPage: true,
|
||||
export default meta
|
||||
type Story = StoryObj<typeof WorkspacesPageView>
|
||||
|
||||
export const AuditPage: Story = {}
|
||||
|
||||
export const Loading = {
|
||||
args: {
|
||||
auditLogs: undefined,
|
||||
count: undefined,
|
||||
isNonInitialPage: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const NoLogs = Template.bind({})
|
||||
NoLogs.args = {
|
||||
auditLogs: [],
|
||||
count: 0,
|
||||
isNonInitialPage: false,
|
||||
export const EmptyPage = {
|
||||
args: {
|
||||
auditLogs: [],
|
||||
isNonInitialPage: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const NotVisible = Template.bind({})
|
||||
NotVisible.args = {
|
||||
isAuditLogVisible: false,
|
||||
export const NoLogs = {
|
||||
args: {
|
||||
auditLogs: [],
|
||||
count: 0,
|
||||
isNonInitialPage: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const AuditPageSmallViewport = Template.bind({})
|
||||
AuditPageSmallViewport.parameters = {
|
||||
chromatic: { viewports: [600] },
|
||||
export const NotVisible = {
|
||||
args: {
|
||||
isAuditLogVisible: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const AuditPageSmallViewport = {
|
||||
parameters: {
|
||||
chromatic: { viewports: [600] },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,16 +13,17 @@ import {
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader"
|
||||
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
|
||||
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { TableLoader } from "components/TableLoader/TableLoader"
|
||||
import { Timeline } from "components/Timeline/Timeline"
|
||||
import { AuditHelpTooltip } from "components/Tooltips"
|
||||
import { FC } from "react"
|
||||
import { ComponentProps, FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
|
||||
import { AuditPaywall } from "./AuditPaywall"
|
||||
import { AuditFilter } from "./AuditFilter"
|
||||
import { PaginationStatus } from "components/PaginationStatus/PaginationStatus"
|
||||
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"
|
||||
|
||||
export const Language = {
|
||||
title: "Audit",
|
||||
@@ -49,23 +50,27 @@ const presetFilters = [
|
||||
export interface AuditPageViewProps {
|
||||
auditLogs?: AuditLog[]
|
||||
count?: number
|
||||
filter: string
|
||||
onFilter: (filter: string) => void
|
||||
paginationRef: PaginationMachineRef
|
||||
page: number
|
||||
limit: number
|
||||
onPageChange: (page: number) => void
|
||||
isNonInitialPage: boolean
|
||||
isAuditLogVisible: boolean
|
||||
error?: Error | unknown
|
||||
filterProps:
|
||||
| ComponentProps<typeof SearchBarWithFilter>
|
||||
| ComponentProps<typeof AuditFilter>
|
||||
}
|
||||
|
||||
export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
auditLogs,
|
||||
count,
|
||||
filter,
|
||||
onFilter,
|
||||
paginationRef,
|
||||
page,
|
||||
limit,
|
||||
onPageChange,
|
||||
isNonInitialPage,
|
||||
isAuditLogVisible,
|
||||
error,
|
||||
filterProps,
|
||||
}) => {
|
||||
const { t } = useTranslation("auditLog")
|
||||
|
||||
@@ -86,12 +91,22 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
|
||||
<ChooseOne>
|
||||
<Cond condition={isAuditLogVisible}>
|
||||
<SearchBarWithFilter
|
||||
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
presetFilters={presetFilters}
|
||||
error={error}
|
||||
{"onFilter" in filterProps ? (
|
||||
<SearchBarWithFilter
|
||||
{...filterProps}
|
||||
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
|
||||
presetFilters={presetFilters}
|
||||
error={error}
|
||||
/>
|
||||
) : (
|
||||
<AuditFilter {...filterProps} />
|
||||
)}
|
||||
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={auditLogs?.length}
|
||||
total={count}
|
||||
label="audit logs"
|
||||
/>
|
||||
|
||||
<TableContainer>
|
||||
@@ -143,7 +158,14 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
|
||||
{count !== undefined && (
|
||||
<PaginationWidgetBase
|
||||
count={count}
|
||||
limit={limit}
|
||||
onChange={onPageChange}
|
||||
page={page}
|
||||
/>
|
||||
)}
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
|
||||
@@ -5,14 +5,11 @@ import { pageTitle } from "utils/page"
|
||||
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
|
||||
import { WorkspacesPageView } from "./WorkspacesPageView"
|
||||
import { useOrganizationId, usePermissions } from "hooks"
|
||||
import {
|
||||
useUserFilterMenu,
|
||||
useTemplateFilterMenu,
|
||||
useStatusFilterMenu,
|
||||
} from "./filter/menus"
|
||||
import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { useFilter } from "components/Filter/filter"
|
||||
import { useUserFilterMenu } from "components/Filter/UserFilter"
|
||||
|
||||
const WorkspacesPage: FC = () => {
|
||||
const orgId = useOrganizationId()
|
||||
@@ -67,6 +64,7 @@ const WorkspacesPage: FC = () => {
|
||||
count={data?.count}
|
||||
page={pagination.page}
|
||||
limit={pagination.limit}
|
||||
onPageChange={pagination.goToPage}
|
||||
filterProps={{
|
||||
filter,
|
||||
menus: {
|
||||
@@ -75,7 +73,6 @@ const WorkspacesPage: FC = () => {
|
||||
status: statusMenu,
|
||||
},
|
||||
}}
|
||||
onPageChange={pagination.goToPage}
|
||||
onUpdateWorkspace={(workspace) => {
|
||||
updateWorkspace.mutate(workspace)
|
||||
}}
|
||||
|
||||
@@ -50,9 +50,9 @@ export interface WorkspacesPageViewProps {
|
||||
workspaces?: Workspace[]
|
||||
count?: number
|
||||
useNewFilter?: boolean
|
||||
filterProps: ComponentProps<typeof WorkspacesFilter>
|
||||
page: number
|
||||
limit: number
|
||||
filterProps: ComponentProps<typeof WorkspacesFilter>
|
||||
onPageChange: (page: number) => void
|
||||
onUpdateWorkspace: (workspace: Workspace) => void
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { FC } from "react"
|
||||
import Box from "@mui/material/Box"
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar"
|
||||
import { Avatar, AvatarProps } from "components/Avatar/Avatar"
|
||||
import { Palette, PaletteColor } from "@mui/material/styles"
|
||||
import { UserFilterMenu, TemplateFilterMenu, StatusFilterMenu } from "./menus"
|
||||
import { UserOption, TemplateOption, StatusOption } from "./options"
|
||||
import { TemplateFilterMenu, StatusFilterMenu } from "./menus"
|
||||
import { TemplateOption, StatusOption } from "./options"
|
||||
import {
|
||||
Filter,
|
||||
FilterMenu,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
SearchFieldSkeleton,
|
||||
useFilter,
|
||||
} from "components/Filter/filter"
|
||||
import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter"
|
||||
import { workspaceFilterQuery } from "utils/filters"
|
||||
|
||||
const PRESET_FILTERS = [
|
||||
@@ -68,46 +68,6 @@ export const WorkspacesFilter = ({
|
||||
)
|
||||
}
|
||||
|
||||
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 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TemplateMenu = (menu: TemplateFilterMenu) => {
|
||||
return (
|
||||
<FilterSearchMenu
|
||||
|
||||
@@ -1,68 +1,9 @@
|
||||
import { UserOption, StatusOption, TemplateOption } from "./options"
|
||||
import { getTemplates, getUsers } from "api/api"
|
||||
import { StatusOption, TemplateOption } from "./options"
|
||||
import { getTemplates } from "api/api"
|
||||
import { WorkspaceStatuses } from "api/typesGenerated"
|
||||
import { getDisplayWorkspaceStatus } from "utils/workspace"
|
||||
import { useMe } from "hooks"
|
||||
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
|
||||
|
||||
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 () => {
|
||||
if (value === "me") {
|
||||
return {
|
||||
label: me.username,
|
||||
value: me.username,
|
||||
avatarUrl: me.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
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 useTemplateFilterMenu = ({
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { BaseOption } from "components/Filter/options"
|
||||
|
||||
export type UserOption = BaseOption & {
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export type StatusOption = BaseOption & {
|
||||
color: string
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { getAuditLogs } from "api/api"
|
||||
import { getErrorMessage } from "api/errors"
|
||||
import { AuditLog, AuditLogResponse } from "api/typesGenerated"
|
||||
import { displayError } from "components/GlobalSnackbar/utils"
|
||||
import { getPaginationData } from "components/PaginationWidget/utils"
|
||||
import {
|
||||
PaginationContext,
|
||||
PaginationMachineRef,
|
||||
paginationMachine,
|
||||
} from "xServices/pagination/paginationXService"
|
||||
import { assign, createMachine, spawn, send } from "xstate"
|
||||
|
||||
const auditPaginationId = "auditPagination"
|
||||
|
||||
interface AuditContext {
|
||||
auditLogs?: AuditLog[]
|
||||
count?: number
|
||||
filter: string
|
||||
paginationContext: PaginationContext
|
||||
paginationRef?: PaginationMachineRef
|
||||
apiError?: Error | unknown
|
||||
}
|
||||
|
||||
export const auditMachine = createMachine(
|
||||
{
|
||||
id: "auditMachine",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./auditXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as AuditContext,
|
||||
services: {} as {
|
||||
loadAuditLogsAndCount: {
|
||||
data: AuditLogResponse
|
||||
}
|
||||
},
|
||||
events: {} as
|
||||
| {
|
||||
type: "UPDATE_PAGE"
|
||||
page: string
|
||||
}
|
||||
| {
|
||||
type: "FILTER"
|
||||
filter: string
|
||||
},
|
||||
},
|
||||
initial: "startPagination",
|
||||
states: {
|
||||
startPagination: {
|
||||
entry: "assignPaginationRef",
|
||||
always: "loading",
|
||||
},
|
||||
loading: {
|
||||
// Right now, XState doesn't a good job with state + context typing so
|
||||
// this forces the AuditPageView to showing the loading state when the
|
||||
// loading state is called again by cleaning up the audit logs data
|
||||
entry: ["clearPreviousAuditLogs", "clearError"],
|
||||
invoke: {
|
||||
src: "loadAuditLogsAndCount",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignAuditLogsAndCount"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["displayApiError", "assignError"],
|
||||
},
|
||||
},
|
||||
onDone: "idle",
|
||||
},
|
||||
idle: {
|
||||
on: {
|
||||
UPDATE_PAGE: {
|
||||
actions: ["updateURL"],
|
||||
target: "loading",
|
||||
},
|
||||
FILTER: {
|
||||
actions: ["assignFilter", "sendResetPage"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
clearPreviousAuditLogs: assign({
|
||||
auditLogs: (_) => undefined,
|
||||
}),
|
||||
assignAuditLogsAndCount: assign({
|
||||
auditLogs: (_, event) => event.data.audit_logs,
|
||||
count: (_, event) => event.data.count,
|
||||
}),
|
||||
assignPaginationRef: assign({
|
||||
paginationRef: (context) =>
|
||||
spawn(
|
||||
paginationMachine.withContext(context.paginationContext),
|
||||
auditPaginationId,
|
||||
),
|
||||
}),
|
||||
assignFilter: assign({
|
||||
filter: (_, { filter }) => filter,
|
||||
}),
|
||||
assignError: assign({
|
||||
apiError: (_, event) => event.data,
|
||||
}),
|
||||
clearError: assign({
|
||||
apiError: (_) => undefined,
|
||||
}),
|
||||
displayApiError: (_, event) => {
|
||||
const message = getErrorMessage(
|
||||
event.data,
|
||||
"Error on loading audit logs.",
|
||||
)
|
||||
displayError(message)
|
||||
},
|
||||
sendResetPage: send({ type: "RESET_PAGE" }, { to: auditPaginationId }),
|
||||
},
|
||||
services: {
|
||||
loadAuditLogsAndCount: async (context) => {
|
||||
if (context.paginationRef) {
|
||||
const { offset, limit } = getPaginationData(context.paginationRef)
|
||||
return getAuditLogs({
|
||||
offset,
|
||||
limit,
|
||||
q: context.filter,
|
||||
})
|
||||
} else {
|
||||
throw new Error("Cannot get audit logs without pagination data")
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user