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

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

+12
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

+6-4
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

+12-2
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>
+46-17
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

+8
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

+20
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

+20
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

+25-3
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)