mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: Add audit logs filtering to the UI (#4120)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user