feat: Add audit logs filtering to the UI (#4120)

This commit is contained in:
Bruno Quaresma
2022-09-19 21:28:23 -03:00
committed by GitHub
parent 7fe7ffea6d
commit 1186e643ec
8 changed files with 149 additions and 26 deletions
+12
View File
@@ -13,6 +13,18 @@ This feature tracks **create, update and delete** events for the following resou
- APIKey
- User
## Filtering logs
In the Coder UI you can filter your audit logs using the pre-defined filter or by using the Coder's filter query like the examples below:
- `resource_type:workspace action:delete` to find deleted workspaces
- `resource_type:template action:create` to find created templates
The supported filters are:
- `resource_type` - The type of the resource. It can be a workspace, template, user, etc. You can [find here](https://pkg.go.dev/github.com/coder/coder@main/codersdk#ResourceType) all the resource types that are supported.
- `action`- The action applied to a resource. You can [find here](https://pkg.go.dev/github.com/coder/coder@main/codersdk#AuditAction) all the actions that are supported.
## Enabling this feature
This feature is autoenabled for all enterprise deployments. An Admin can contact us to purchase a license [here](https://coder.com/contact?note=I%20want%20to%20upgrade%20my%20license).
@@ -45,7 +45,9 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen)
const diffs = Object.entries(auditLog.diff)
const shouldDisplayDiff = diffs.length > 0
const userAgent = userAgentParser(auditLog.user_agent)
const { os, browser } = userAgentParser(auditLog.user_agent)
const notAvailableLabel = "Not available"
const displayBrowserInfo = browser.name ? `${browser.name} ${browser.version}` : notAvailableLabel
const toggle = () => {
if (shouldDisplayDiff) {
@@ -101,13 +103,13 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
/>
<Stack direction="row" alignItems="center" className={styles.auditLogExtraInfo}>
<div>
<strong>IP</strong> {auditLog.ip}
<strong>IP</strong> {auditLog.ip ?? notAvailableLabel}
</div>
<div>
<strong>OS</strong> {userAgent.os.name}
<strong>OS</strong> {os.name ?? notAvailableLabel}
</div>
<div>
<strong>Browser</strong> {userAgent.browser.name} {userAgent.browser.version}
<strong>Browser</strong> {displayBrowserInfo}
</div>
</Stack>
</Stack>
@@ -22,6 +22,7 @@ export interface SearchBarWithFilterProps {
onFilter: (query: string) => void
presetFilters?: PresetFilter[]
error?: unknown
docs?: string
}
export interface PresetFilter {
@@ -34,6 +35,7 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
onFilter,
presetFilters,
error,
docs,
}) => {
const styles = useStyles({ error: Boolean(error) })
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -99,6 +101,9 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
debouncedOnFilter(event.currentTarget.value)
}}
inputRef={searchInputRef}
inputProps={{
"aria-label": "Filter",
}}
startAdornment={
<InputAdornment position="start" className={styles.searchIcon}>
<SearchIcon fontSize="small" />
@@ -107,7 +112,7 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
/>
</div>
{presetFilters && presetFilters.length && (
{presetFilters && presetFilters.length ? (
<Menu
id="filter-menu"
anchorEl={anchorEl}
@@ -129,8 +134,13 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
{presetFilter.name}
</MenuItem>
))}
{docs && (
<MenuItem component="a" href={docs} target="_blank">
View advanced filtering
</MenuItem>
)}
</Menu>
)}
) : null}
</Stack>
{errorMessage && <Stack className={styles.errorRoot}>{errorMessage}</Stack>}
</Stack>
+46 -17
View File
@@ -1,10 +1,15 @@
import { fireEvent, screen } from "@testing-library/react"
import { Language as AuditTooltipLanguage } from "components/Tooltips/AuditHelpTooltip"
import { Language as TooltipLanguage } from "components/Tooltips/HelpTooltip/HelpTooltip"
import { MockAuditLog, MockAuditLog2, render } from "testHelpers/renderHelpers"
import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import {
history,
MockAuditLog,
MockAuditLog2,
render,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import * as CreateDayString from "util/createDayString"
import AuditPage from "./AuditPage"
import { Language as AuditViewLanguage } from "./AuditPageView"
describe("AuditPage", () => {
beforeEach(() => {
@@ -13,18 +18,6 @@ describe("AuditPage", () => {
mock.mockImplementation(() => "a minute ago")
})
it("renders a page with a title and subtitle", async () => {
// When
render(<AuditPage />)
// Then
await screen.findByText(AuditViewLanguage.title)
await screen.findByText(AuditViewLanguage.subtitle)
const tooltipIcon = await screen.findByRole("button", { name: TooltipLanguage.ariaLabel })
fireEvent.mouseOver(tooltipIcon)
expect(await screen.findByText(AuditTooltipLanguage.title)).toBeInTheDocument()
})
it("shows the audit logs", async () => {
// When
render(<AuditPage />)
@@ -33,4 +26,40 @@ describe("AuditPage", () => {
await screen.findByTestId(`audit-log-row-${MockAuditLog.id}`)
screen.getByTestId(`audit-log-row-${MockAuditLog2.id}`)
})
describe("Filtering", () => {
it("filters by typing", async () => {
const getAuditLogsSpy = jest
.spyOn(API, "getAuditLogs")
.mockResolvedValue({ audit_logs: [MockAuditLog] })
render(<AuditPage />)
await waitForLoaderToBeRemoved()
// Reset spy so we can focus on the call with the filter
getAuditLogsSpy.mockReset()
const filterField = screen.getByLabelText("Filter")
const query = "resource_type:workspace action:create"
await userEvent.type(filterField, query)
await waitFor(() =>
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query }),
)
})
it("filters by URL", async () => {
const getAuditLogsSpy = jest
.spyOn(API, "getAuditLogs")
.mockResolvedValue({ audit_logs: [MockAuditLog] })
const query = "resource_type:workspace action:create"
history.push(`/audit?filter=${encodeURIComponent(query)}`)
render(<AuditPage />)
await waitForLoaderToBeRemoved()
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
})
})
})
+8
View File
@@ -2,6 +2,7 @@ import { useMachine } from "@xstate/react"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useFilter } from "util/filters"
import { pageTitle } from "util/page"
import { auditMachine } from "xServices/audit/auditXService"
import { AuditPageView } from "./AuditPageView"
@@ -10,10 +11,12 @@ const AuditPage: FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const currentPage = searchParams.get("page") ? Number(searchParams.get("page")) : 1
const { filter, setFilter } = useFilter("")
const [auditState, auditSend] = useMachine(auditMachine, {
context: {
page: currentPage,
limit: 25,
filter,
},
actions: {
onPageChange: ({ page }) => {
@@ -31,6 +34,7 @@ const AuditPage: FC = () => {
<title>{pageTitle("Audit")}</title>
</Helmet>
<AuditPageView
filter={filter}
auditLogs={auditLogs}
count={count}
page={page}
@@ -44,6 +48,10 @@ const AuditPage: FC = () => {
onGoToPage={(page) => {
auditSend("GO_TO_PAGE", { page })
}}
onFilter={(filter) => {
setFilter(filter)
auditSend("FILTER", { filter })
}}
/>
</>
)
@@ -10,6 +10,7 @@ import { EmptyState } from "components/EmptyState/EmptyState"
import { Margins } from "components/Margins/Margins"
import { PageHeader, 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 { AuditHelpTooltip } from "components/Tooltips"
@@ -20,11 +21,21 @@ export const Language = {
subtitle: "View events in your audit log.",
}
const presetFilters = [
{ query: "resource_type:workspace action:create", name: "Created workspaces" },
{ query: "resource_type:template action:create", name: "Added templates" },
{ query: "resource_type:user action:create", name: "Added users" },
{ query: "resource_type:template action:delete", name: "Deleted templates" },
{ query: "resource_type:user action:delete", name: "Deleted users" },
]
export interface AuditPageViewProps {
auditLogs?: AuditLog[]
count?: number
page: number
limit: number
filter: string
onFilter: (filter: string) => void
onNext: () => void
onPrevious: () => void
onGoToPage: (page: number) => void
@@ -35,6 +46,8 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
count,
page,
limit,
filter,
onFilter,
onNext,
onPrevious,
onGoToPage,
@@ -55,6 +68,13 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle>
</PageHeader>
<SearchBarWithFilter
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
/>
<TableContainer>
<Table>
<TableHead>
+20
View File
@@ -1,3 +1,4 @@
import { useSearchParams } from "react-router-dom"
import * as TypesGen from "../api/typesGenerated"
export const queryToFilter = (query?: string): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
@@ -16,3 +17,22 @@ export const userFilterQuery = {
active: "status:active",
all: "",
}
export const useFilter = (
defaultFilter: string,
): {
filter: string
setFilter: (filter: string) => void
} => {
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get("filter") ?? defaultFilter
const setFilter = (filter: string) => {
setSearchParams({ filter })
}
return {
filter,
setFilter,
}
}
+25 -3
View File
@@ -4,13 +4,21 @@ import { AuditLog } from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
interface AuditContext {
auditLogs?: AuditLog[]
count?: number
page: number
limit: number
filter: string
}
export const auditMachine = createMachine(
{
id: "auditMachine",
predictableActionArguments: true,
tsTypes: {} as import("./auditXService.typegen").Typegen0,
schema: {
context: {} as { auditLogs?: AuditLog[]; count?: number; page: number; limit: number },
context: {} as AuditContext,
services: {} as {
loadAuditLogsAndCount: {
data: {
@@ -29,6 +37,10 @@ export const auditMachine = createMachine(
| {
type: "GO_TO_PAGE"
page: number
}
| {
type: "FILTER"
filter: string
},
},
initial: "loading",
@@ -65,6 +77,10 @@ export const auditMachine = createMachine(
actions: ["assignPage", "onPageChange"],
target: "loading",
},
FILTER: {
actions: ["assignFilter"],
target: "loading",
},
},
},
error: {
@@ -90,20 +106,26 @@ export const auditMachine = createMachine(
assignPage: assign({
page: (_, { page }) => page,
}),
assignFilter: assign({
filter: (_, { filter }) => filter,
}),
displayApiError: (_, event) => {
const message = getErrorMessage(event.data, "Error on loading audit logs.")
displayError(message)
},
},
services: {
loadAuditLogsAndCount: async ({ page, limit }, _) => {
loadAuditLogsAndCount: async ({ page, limit, filter }, _) => {
const [auditLogs, count] = await Promise.all([
getAuditLogs({
// The page in the API starts at 0
offset: (page - 1) * limit,
limit,
q: filter,
}).then((data) => data.audit_logs),
getAuditLogsCount().then((data) => data.count),
getAuditLogsCount({
q: filter,
}).then((data) => data.count),
])
return {