diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 09c25962d458c..93c98770753bd 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -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). diff --git a/site/src/components/AuditLogRow/AuditLogRow.tsx b/site/src/components/AuditLogRow/AuditLogRow.tsx index 83d304f5ef2e9..9f255898cfd6d 100644 --- a/site/src/components/AuditLogRow/AuditLogRow.tsx +++ b/site/src/components/AuditLogRow/AuditLogRow.tsx @@ -45,7 +45,9 @@ export const AuditLogRow: React.FC = ({ 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 = ({ />
- IP {auditLog.ip} + IP {auditLog.ip ?? notAvailableLabel}
- OS {userAgent.os.name} + OS {os.name ?? notAvailableLabel}
- Browser {userAgent.browser.name} {userAgent.browser.version} + Browser {displayBrowserInfo}
diff --git a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx index 7e1fe9f14bde4..aa1292ddd5ae9 100644 --- a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx +++ b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx @@ -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 { const styles = useStyles({ error: Boolean(error) }) const searchInputRef = useRef(null) @@ -99,6 +101,9 @@ export const SearchBarWithFilter: React.FC @@ -107,7 +112,7 @@ export const SearchBarWithFilter: React.FC - {presetFilters && presetFilters.length && ( + {presetFilters && presetFilters.length ? ( ))} + {docs && ( + + View advanced filtering + + )} - )} + ) : null} {errorMessage && {errorMessage}} diff --git a/site/src/pages/AuditPage/AuditPage.test.tsx b/site/src/pages/AuditPage/AuditPage.test.tsx index 4ddfb5119cad8..7e6387417ee73 100644 --- a/site/src/pages/AuditPage/AuditPage.test.tsx +++ b/site/src/pages/AuditPage/AuditPage.test.tsx @@ -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() - - // 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() @@ -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() + 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() + + await waitForLoaderToBeRemoved() + + expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query }) + }) + }) }) diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 342b0fbc8dc8a..350f6f7130305 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -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 = () => { Codestin Search App { onGoToPage={(page) => { auditSend("GO_TO_PAGE", { page }) }} + onFilter={(filter) => { + setFilter(filter) + auditSend("FILTER", { filter }) + }} /> ) diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index ea5b4781a2d4d..31554652b12d8 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -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 = ({ count, page, limit, + filter, + onFilter, onNext, onPrevious, onGoToPage, @@ -55,6 +68,13 @@ export const AuditPageView: FC = ({ {Language.subtitle} + + diff --git a/site/src/util/filters.ts b/site/src/util/filters.ts index 461507411d7e5..a652eb11e5f18 100644 --- a/site/src/util/filters.ts +++ b/site/src/util/filters.ts @@ -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, + } +} diff --git a/site/src/xServices/audit/auditXService.ts b/site/src/xServices/audit/auditXService.ts index 2ab414900d79b..9c315bec2a860 100644 --- a/site/src/xServices/audit/auditXService.ts +++ b/site/src/xServices/audit/auditXService.ts @@ -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 {