Skip to content

Commit 1186e64

Browse files
feat: Add audit logs filtering to the UI (#4120)
1 parent 7fe7ffe commit 1186e64

File tree

8 files changed

+149
-26
lines changed

8 files changed

+149
-26
lines changed

docs/admin/audit-logs.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ This feature tracks **create, update and delete** events for the following resou
1313
- APIKey
1414
- User
1515

16+
## Filtering logs
17+
18+
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:
19+
20+
- `resource_type:workspace action:delete` to find deleted workspaces
21+
- `resource_type:template action:create` to find created templates
22+
23+
The supported filters are:
24+
25+
- `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.
26+
- `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.
27+
1628
## Enabling this feature
1729

1830
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).

site/src/components/AuditLogRow/AuditLogRow.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
4545
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen)
4646
const diffs = Object.entries(auditLog.diff)
4747
const shouldDisplayDiff = diffs.length > 0
48-
const userAgent = userAgentParser(auditLog.user_agent)
48+
const { os, browser } = userAgentParser(auditLog.user_agent)
49+
const notAvailableLabel = "Not available"
50+
const displayBrowserInfo = browser.name ? `${browser.name} ${browser.version}` : notAvailableLabel
4951

5052
const toggle = () => {
5153
if (shouldDisplayDiff) {
@@ -101,13 +103,13 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
101103
/>
102104
<Stack direction="row" alignItems="center" className={styles.auditLogExtraInfo}>
103105
<div>
104-
<strong>IP</strong> {auditLog.ip}
106+
<strong>IP</strong> {auditLog.ip ?? notAvailableLabel}
105107
</div>
106108
<div>
107-
<strong>OS</strong> {userAgent.os.name}
109+
<strong>OS</strong> {os.name ?? notAvailableLabel}
108110
</div>
109111
<div>
110-
<strong>Browser</strong> {userAgent.browser.name} {userAgent.browser.version}
112+
<strong>Browser</strong> {displayBrowserInfo}
111113
</div>
112114
</Stack>
113115
</Stack>

site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface SearchBarWithFilterProps {
2222
onFilter: (query: string) => void
2323
presetFilters?: PresetFilter[]
2424
error?: unknown
25+
docs?: string
2526
}
2627

2728
export interface PresetFilter {
@@ -34,6 +35,7 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
3435
onFilter,
3536
presetFilters,
3637
error,
38+
docs,
3739
}) => {
3840
const styles = useStyles({ error: Boolean(error) })
3941
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -99,6 +101,9 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
99101
debouncedOnFilter(event.currentTarget.value)
100102
}}
101103
inputRef={searchInputRef}
104+
inputProps={{
105+
"aria-label": "Filter",
106+
}}
102107
startAdornment={
103108
<InputAdornment position="start" className={styles.searchIcon}>
104109
<SearchIcon fontSize="small" />
@@ -107,7 +112,7 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
107112
/>
108113
</div>
109114

110-
{presetFilters && presetFilters.length && (
115+
{presetFilters && presetFilters.length ? (
111116
<Menu
112117
id="filter-menu"
113118
anchorEl={anchorEl}
@@ -129,8 +134,13 @@ export const SearchBarWithFilter: React.FC<React.PropsWithChildren<SearchBarWith
129134
{presetFilter.name}
130135
</MenuItem>
131136
))}
137+
{docs && (
138+
<MenuItem component="a" href={docs} target="_blank">
139+
View advanced filtering
140+
</MenuItem>
141+
)}
132142
</Menu>
133-
)}
143+
) : null}
134144
</Stack>
135145
{errorMessage && <Stack className={styles.errorRoot}>{errorMessage}</Stack>}
136146
</Stack>
Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { fireEvent, screen } from "@testing-library/react"
2-
import { Language as AuditTooltipLanguage } from "components/Tooltips/AuditHelpTooltip"
3-
import { Language as TooltipLanguage } from "components/Tooltips/HelpTooltip/HelpTooltip"
4-
import { MockAuditLog, MockAuditLog2, render } from "testHelpers/renderHelpers"
1+
import { screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import * as API from "api/api"
4+
import {
5+
history,
6+
MockAuditLog,
7+
MockAuditLog2,
8+
render,
9+
waitForLoaderToBeRemoved,
10+
} from "testHelpers/renderHelpers"
511
import * as CreateDayString from "util/createDayString"
612
import AuditPage from "./AuditPage"
7-
import { Language as AuditViewLanguage } from "./AuditPageView"
813

914
describe("AuditPage", () => {
1015
beforeEach(() => {
@@ -13,18 +18,6 @@ describe("AuditPage", () => {
1318
mock.mockImplementation(() => "a minute ago")
1419
})
1520

16-
it("renders a page with a title and subtitle", async () => {
17-
// When
18-
render(<AuditPage />)
19-
20-
// Then
21-
await screen.findByText(AuditViewLanguage.title)
22-
await screen.findByText(AuditViewLanguage.subtitle)
23-
const tooltipIcon = await screen.findByRole("button", { name: TooltipLanguage.ariaLabel })
24-
fireEvent.mouseOver(tooltipIcon)
25-
expect(await screen.findByText(AuditTooltipLanguage.title)).toBeInTheDocument()
26-
})
27-
2821
it("shows the audit logs", async () => {
2922
// When
3023
render(<AuditPage />)
@@ -33,4 +26,40 @@ describe("AuditPage", () => {
3326
await screen.findByTestId(`audit-log-row-${MockAuditLog.id}`)
3427
screen.getByTestId(`audit-log-row-${MockAuditLog2.id}`)
3528
})
29+
30+
describe("Filtering", () => {
31+
it("filters by typing", async () => {
32+
const getAuditLogsSpy = jest
33+
.spyOn(API, "getAuditLogs")
34+
.mockResolvedValue({ audit_logs: [MockAuditLog] })
35+
36+
render(<AuditPage />)
37+
await waitForLoaderToBeRemoved()
38+
39+
// Reset spy so we can focus on the call with the filter
40+
getAuditLogsSpy.mockReset()
41+
42+
const filterField = screen.getByLabelText("Filter")
43+
const query = "resource_type:workspace action:create"
44+
await userEvent.type(filterField, query)
45+
46+
await waitFor(() =>
47+
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query }),
48+
)
49+
})
50+
51+
it("filters by URL", async () => {
52+
const getAuditLogsSpy = jest
53+
.spyOn(API, "getAuditLogs")
54+
.mockResolvedValue({ audit_logs: [MockAuditLog] })
55+
56+
const query = "resource_type:workspace action:create"
57+
history.push(`/audit?filter=${encodeURIComponent(query)}`)
58+
render(<AuditPage />)
59+
60+
await waitForLoaderToBeRemoved()
61+
62+
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
63+
})
64+
})
3665
})

site/src/pages/AuditPage/AuditPage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMachine } from "@xstate/react"
22
import { FC } from "react"
33
import { Helmet } from "react-helmet-async"
44
import { useNavigate, useSearchParams } from "react-router-dom"
5+
import { useFilter } from "util/filters"
56
import { pageTitle } from "util/page"
67
import { auditMachine } from "xServices/audit/auditXService"
78
import { AuditPageView } from "./AuditPageView"
@@ -10,10 +11,12 @@ const AuditPage: FC = () => {
1011
const navigate = useNavigate()
1112
const [searchParams] = useSearchParams()
1213
const currentPage = searchParams.get("page") ? Number(searchParams.get("page")) : 1
14+
const { filter, setFilter } = useFilter("")
1315
const [auditState, auditSend] = useMachine(auditMachine, {
1416
context: {
1517
page: currentPage,
1618
limit: 25,
19+
filter,
1720
},
1821
actions: {
1922
onPageChange: ({ page }) => {
@@ -31,6 +34,7 @@ const AuditPage: FC = () => {
3134
<title>{pageTitle("Audit")}</title>
3235
</Helmet>
3336
<AuditPageView
37+
filter={filter}
3438
auditLogs={auditLogs}
3539
count={count}
3640
page={page}
@@ -44,6 +48,10 @@ const AuditPage: FC = () => {
4448
onGoToPage={(page) => {
4549
auditSend("GO_TO_PAGE", { page })
4650
}}
51+
onFilter={(filter) => {
52+
setFilter(filter)
53+
auditSend("FILTER", { filter })
54+
}}
4755
/>
4856
</>
4957
)

site/src/pages/AuditPage/AuditPageView.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { EmptyState } from "components/EmptyState/EmptyState"
1010
import { Margins } from "components/Margins/Margins"
1111
import { PageHeader, PageHeaderSubtitle, PageHeaderTitle } from "components/PageHeader/PageHeader"
1212
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
13+
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
1314
import { Stack } from "components/Stack/Stack"
1415
import { TableLoader } from "components/TableLoader/TableLoader"
1516
import { AuditHelpTooltip } from "components/Tooltips"
@@ -20,11 +21,21 @@ export const Language = {
2021
subtitle: "View events in your audit log.",
2122
}
2223

24+
const presetFilters = [
25+
{ query: "resource_type:workspace action:create", name: "Created workspaces" },
26+
{ query: "resource_type:template action:create", name: "Added templates" },
27+
{ query: "resource_type:user action:create", name: "Added users" },
28+
{ query: "resource_type:template action:delete", name: "Deleted templates" },
29+
{ query: "resource_type:user action:delete", name: "Deleted users" },
30+
]
31+
2332
export interface AuditPageViewProps {
2433
auditLogs?: AuditLog[]
2534
count?: number
2635
page: number
2736
limit: number
37+
filter: string
38+
onFilter: (filter: string) => void
2839
onNext: () => void
2940
onPrevious: () => void
3041
onGoToPage: (page: number) => void
@@ -35,6 +46,8 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
3546
count,
3647
page,
3748
limit,
49+
filter,
50+
onFilter,
3851
onNext,
3952
onPrevious,
4053
onGoToPage,
@@ -55,6 +68,13 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
5568
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle>
5669
</PageHeader>
5770

71+
<SearchBarWithFilter
72+
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
73+
filter={filter}
74+
onFilter={onFilter}
75+
presetFilters={presetFilters}
76+
/>
77+
5878
<TableContainer>
5979
<Table>
6080
<TableHead>

site/src/util/filters.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useSearchParams } from "react-router-dom"
12
import * as TypesGen from "../api/typesGenerated"
23

34
export const queryToFilter = (query?: string): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
@@ -16,3 +17,22 @@ export const userFilterQuery = {
1617
active: "status:active",
1718
all: "",
1819
}
20+
21+
export const useFilter = (
22+
defaultFilter: string,
23+
): {
24+
filter: string
25+
setFilter: (filter: string) => void
26+
} => {
27+
const [searchParams, setSearchParams] = useSearchParams()
28+
const filter = searchParams.get("filter") ?? defaultFilter
29+
30+
const setFilter = (filter: string) => {
31+
setSearchParams({ filter })
32+
}
33+
34+
return {
35+
filter,
36+
setFilter,
37+
}
38+
}

site/src/xServices/audit/auditXService.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import { AuditLog } from "api/typesGenerated"
44
import { displayError } from "components/GlobalSnackbar/utils"
55
import { assign, createMachine } from "xstate"
66

7+
interface AuditContext {
8+
auditLogs?: AuditLog[]
9+
count?: number
10+
page: number
11+
limit: number
12+
filter: string
13+
}
14+
715
export const auditMachine = createMachine(
816
{
917
id: "auditMachine",
1018
predictableActionArguments: true,
1119
tsTypes: {} as import("./auditXService.typegen").Typegen0,
1220
schema: {
13-
context: {} as { auditLogs?: AuditLog[]; count?: number; page: number; limit: number },
21+
context: {} as AuditContext,
1422
services: {} as {
1523
loadAuditLogsAndCount: {
1624
data: {
@@ -29,6 +37,10 @@ export const auditMachine = createMachine(
2937
| {
3038
type: "GO_TO_PAGE"
3139
page: number
40+
}
41+
| {
42+
type: "FILTER"
43+
filter: string
3244
},
3345
},
3446
initial: "loading",
@@ -65,6 +77,10 @@ export const auditMachine = createMachine(
6577
actions: ["assignPage", "onPageChange"],
6678
target: "loading",
6779
},
80+
FILTER: {
81+
actions: ["assignFilter"],
82+
target: "loading",
83+
},
6884
},
6985
},
7086
error: {
@@ -90,20 +106,26 @@ export const auditMachine = createMachine(
90106
assignPage: assign({
91107
page: (_, { page }) => page,
92108
}),
109+
assignFilter: assign({
110+
filter: (_, { filter }) => filter,
111+
}),
93112
displayApiError: (_, event) => {
94113
const message = getErrorMessage(event.data, "Error on loading audit logs.")
95114
displayError(message)
96115
},
97116
},
98117
services: {
99-
loadAuditLogsAndCount: async ({ page, limit }, _) => {
118+
loadAuditLogsAndCount: async ({ page, limit, filter }, _) => {
100119
const [auditLogs, count] = await Promise.all([
101120
getAuditLogs({
102121
// The page in the API starts at 0
103122
offset: (page - 1) * limit,
104123
limit,
124+
q: filter,
105125
}).then((data) => data.audit_logs),
106-
getAuditLogsCount().then((data) => data.count),
126+
getAuditLogsCount({
127+
q: filter,
128+
}).then((data) => data.count),
107129
])
108130

109131
return {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy