Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: Add audit logs filtering to the UI #4120

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

Merged
merged 7 commits into from
Sep 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/admin/audit-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bpmct I would appreciate your help with this doc

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works well for now! Sorry I missed it

## 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).
10 changes: 6 additions & 4 deletions site/src/components/AuditLogRow/AuditLogRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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>
Expand Down
14 changes: 12 additions & 2 deletions site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SearchBarWithFilterProps {
onFilter: (query: string) => void
presetFilters?: PresetFilter[]
error?: unknown
docs?: string
}

export interface PresetFilter {
Expand All @@ -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)
Expand Down Expand Up @@ -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" />
Expand All @@ -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}
Expand All @@ -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>
Expand Down
63 changes: 46 additions & 17 deletions site/src/pages/AuditPage/AuditPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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 />)
Expand All @@ -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 })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice tests 👏🏼

})
})
})
8 changes: 8 additions & 0 deletions site/src/pages/AuditPage/AuditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }) => {
Expand All @@ -31,6 +34,7 @@ const AuditPage: FC = () => {
<title>{pageTitle("Audit")}</title>
</Helmet>
<AuditPageView
filter={filter}
auditLogs={auditLogs}
count={count}
page={page}
Expand All @@ -44,6 +48,10 @@ const AuditPage: FC = () => {
onGoToPage={(page) => {
auditSend("GO_TO_PAGE", { page })
}}
onFilter={(filter) => {
setFilter(filter)
auditSend("FILTER", { filter })
}}
/>
</>
)
Expand Down
20 changes: 20 additions & 0 deletions site/src/pages/AuditPage/AuditPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -35,6 +46,8 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
count,
page,
limit,
filter,
onFilter,
onNext,
onPrevious,
onGoToPage,
Expand All @@ -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>
Expand Down
20 changes: 20 additions & 0 deletions site/src/util/filters.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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,
}
}
28 changes: 25 additions & 3 deletions site/src/xServices/audit/auditXService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -29,6 +37,10 @@ export const auditMachine = createMachine(
| {
type: "GO_TO_PAGE"
page: number
}
| {
type: "FILTER"
filter: string
},
},
initial: "loading",
Expand Down Expand Up @@ -65,6 +77,10 @@ export const auditMachine = createMachine(
actions: ["assignPage", "onPageChange"],
target: "loading",
},
FILTER: {
actions: ["assignFilter"],
target: "loading",
},
},
},
error: {
Expand All @@ -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 {
Expand Down