diff --git a/site/package.json b/site/package.json index 70ed8cca3d33c..d79a49cb81fb2 100644 --- a/site/package.json +++ b/site/package.json @@ -60,6 +60,7 @@ "sourcemapped-stacktrace": "1.1.11", "swr": "1.3.0", "tzdata": "1.0.30", + "ua-parser-js": "1.0.2", "uuid": "9.0.0", "xstate": "4.33.5", "xterm": "4.19.0", @@ -87,6 +88,7 @@ "@types/react-helmet": "6.1.5", "@types/semver": "^7.3.12", "@types/superagent": "4.1.15", + "@types/ua-parser-js": "^0.7.36", "@types/uuid": "8.3.4", "@typescript-eslint/eslint-plugin": "5.36.1", "@typescript-eslint/parser": "5.31.0", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index a73fa64921da4..55c4a975371db 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -4,7 +4,7 @@ import { RequirePermission } from "components/RequirePermission/RequirePermissio import { SetupPage } from "pages/SetupPage/SetupPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" import { FC, lazy, Suspense, useContext } from "react" -import { Navigate, Route, Routes } from "react-router-dom" +import { Route, Routes } from "react-router-dom" import { selectPermissions } from "xServices/auth/authSelectors" import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" import { XServiceContext } from "xServices/StateContext" @@ -139,21 +139,17 @@ export const AppRouter: FC = () => { - ) : ( - - - - - - ) + + + + + } - > + /> }> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3544772e3fd15..a285248c309ea 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -389,6 +389,23 @@ export const getEntitlements = async (): Promise => { return response.data } +interface GetAuditLogsOptions { + limit: number + offset: number +} + +export const getAuditLogs = async ( + options: GetAuditLogsOptions, +): Promise => { + const response = await axios.get(`/api/v2/audit?limit=${options.limit}&offset=${options.offset}`) + return response.data +} + +export const getAuditLogsCount = async (): Promise => { + const response = await axios.get(`/api/v2/audit/count`) + return response.data +} + export const getTemplateDAUs = async ( templateId: string, ): Promise => { diff --git a/site/src/components/AuditLogRow/AuditLogDiff.tsx b/site/src/components/AuditLogRow/AuditLogDiff.tsx new file mode 100644 index 0000000000000..6dffe114018bd --- /dev/null +++ b/site/src/components/AuditLogRow/AuditLogDiff.tsx @@ -0,0 +1,109 @@ +import { makeStyles } from "@material-ui/core/styles" +import { AuditLog } from "api/typesGenerated" +import { colors } from "theme/colors" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import { combineClasses } from "util/combineClasses" + +const getDiffValue = (value: number | string | boolean) => { + if (typeof value === "string") { + return `"${value}"` + } + + return value.toString() +} + +export const AuditLogDiff: React.FC<{ diff: AuditLog["diff"] }> = ({ diff }) => { + const styles = useStyles() + const diffEntries = Object.entries(diff) + + return ( +
+
+ {diffEntries.map(([attrName, valueDiff], index) => ( +
+
{index + 1}
+
-
+
+ {attrName}:{" "} + + {getDiffValue(valueDiff.old)} + +
+
+ ))} +
+
+ {diffEntries.map(([attrName, valueDiff], index) => ( +
+
{index + 1}
+
+
+
+ {attrName}:{" "} + + {getDiffValue(valueDiff.new)} + +
+
+ ))} +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + diff: { + display: "flex", + alignItems: "flex-start", + fontSize: theme.typography.body2.fontSize, + borderTop: `1px solid ${theme.palette.divider}`, + fontFamily: MONOSPACE_FONT_FAMILY, + }, + + diffColumn: { + flex: 1, + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2.5), + lineHeight: "160%", + }, + + diffOld: { + backgroundColor: theme.palette.error.dark, + color: theme.palette.error.contrastText, + }, + + diffRow: { + display: "flex", + alignItems: "baseline", + }, + + diffLine: { + opacity: 0.5, + width: theme.spacing(8), + textAlign: "right", + flexShrink: 0, + }, + + diffIcon: { + width: theme.spacing(4), + textAlign: "center", + fontSize: theme.typography.body1.fontSize, + }, + + diffNew: { + backgroundColor: theme.palette.success.dark, + color: theme.palette.success.contrastText, + }, + + diffValue: { + padding: 1, + borderRadius: theme.shape.borderRadius / 2, + }, + + diffValueOld: { + backgroundColor: colors.red[12], + }, + + diffValueNew: { + backgroundColor: colors.green[12], + }, +})) diff --git a/site/src/components/AuditLogRow/AuditLogRow.stories.tsx b/site/src/components/AuditLogRow/AuditLogRow.stories.tsx new file mode 100644 index 0000000000000..b552b868ac240 --- /dev/null +++ b/site/src/components/AuditLogRow/AuditLogRow.stories.tsx @@ -0,0 +1,40 @@ +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { ComponentMeta, Story } from "@storybook/react" +import { MockAuditLog, MockAuditLog2 } from "testHelpers/entities" +import { AuditLogRow, AuditLogRowProps } from "./AuditLogRow" + +export default { + title: "components/AuditLogRow", + component: AuditLogRow, +} as ComponentMeta + +const Template: Story = (args) => ( + + + + + Logs + + + + + +
+
+) + +export const NoDiff = Template.bind({}) +NoDiff.args = { + auditLog: MockAuditLog, +} + +export const WithDiff = Template.bind({}) +WithDiff.args = { + auditLog: MockAuditLog2, + defaultIsDiffOpen: true, +} diff --git a/site/src/components/AuditLogRow/AuditLogRow.tsx b/site/src/components/AuditLogRow/AuditLogRow.tsx new file mode 100644 index 0000000000000..1974956c05303 --- /dev/null +++ b/site/src/components/AuditLogRow/AuditLogRow.tsx @@ -0,0 +1,191 @@ +import Collapse from "@material-ui/core/Collapse" +import { makeStyles } from "@material-ui/core/styles" +import TableCell from "@material-ui/core/TableCell" +import TableRow from "@material-ui/core/TableRow" +import { AuditLog } from "api/typesGenerated" +import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" +import { Pill } from "components/Pill/Pill" +import { Stack } from "components/Stack/Stack" +import { UserAvatar } from "components/UserAvatar/UserAvatar" +import { t } from "i18next" +import { ComponentProps, useState } from "react" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import userAgentParser from "ua-parser-js" +import { createDayString } from "util/createDayString" +import { AuditLogDiff } from "./AuditLogDiff" + +const pillTypeByHttpStatus = (httpStatus: number): ComponentProps["type"] => { + if (httpStatus >= 300 && httpStatus < 500) { + return "warning" + } + + if (httpStatus >= 500) { + return "error" + } + + return "success" +} + +const actionLabelByAction: Record = { + create: t("actions.create", { ns: "auditLog" }), + write: t("actions.write", { ns: "auditLog" }), + delete: t("actions.delete", { ns: "auditLog" }), +} + +const resourceLabelByResourceType: Record = { + organization: "organization", + template: "template", + template_version: "template version", + user: "user", + workspace: "workspace", +} + +const readableActionMessage = (auditLog: AuditLog) => { + return `${actionLabelByAction[auditLog.action]} ${ + resourceLabelByResourceType[auditLog.resource_type] + }` +} + +export interface AuditLogRowProps { + auditLog: AuditLog + // Useful for Storybook + defaultIsDiffOpen?: boolean +} + +export const AuditLogRow: React.FC = ({ + auditLog, + defaultIsDiffOpen = false, +}) => { + const styles = useStyles() + const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen) + const diffs = Object.entries(auditLog.diff) + const shouldDisplayDiff = diffs.length > 0 + const userAgent = userAgentParser(auditLog.user_agent) + + const toggle = () => { + if (shouldDisplayDiff) { + setIsDiffOpen((v) => !v) + } + } + + return ( + + + { + if (event.key === "Enter") { + toggle() + } + }} + > + + + +
+ + {auditLog.user?.username} {readableActionMessage(auditLog)}{" "} + {auditLog.resource_target} + + {createDayString(auditLog.time)} +
+
+ + + + +
+ IP {auditLog.ip} +
+
+ OS {userAgent.os.name} +
+
+ Browser {userAgent.browser.name} {userAgent.browser.version} +
+
+
+
+ +
+ {isDiffOpen ? : } +
+
+ + {shouldDisplayDiff && ( + + + + )} +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + auditLogCell: { + padding: "0 !important", + }, + + auditLogRow: { + padding: theme.spacing(2, 4), + + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + }, + + auditLogRowInfo: { + flex: 1, + }, + + auditLogResume: { + ...theme.typography.body1, + fontFamily: "inherit", + display: "block", + }, + + auditLogTime: { + ...theme.typography.body2, + fontSize: 12, + fontFamily: "inherit", + color: theme.palette.text.secondary, + display: "block", + }, + + auditLogRight: { + width: "auto", + }, + + auditLogExtraInfo: { + ...theme.typography.body2, + fontFamily: MONOSPACE_FONT_FAMILY, + color: theme.palette.text.secondary, + whiteSpace: "nowrap", + }, + + disabledDropdownIcon: { + opacity: 0.5, + }, +})) diff --git a/site/src/components/Stack/Stack.tsx b/site/src/components/Stack/Stack.tsx index d5424e6a0bbcb..d12f4e5821a56 100644 --- a/site/src/components/Stack/Stack.tsx +++ b/site/src/components/Stack/Stack.tsx @@ -6,13 +6,13 @@ import { combineClasses } from "../../util/combineClasses" type Direction = "column" | "row" -export interface StackProps { +export type StackProps = { className?: string direction?: Direction spacing?: number alignItems?: CSSProperties["alignItems"] justifyContent?: CSSProperties["justifyContent"] -} +} & React.HTMLProps type StyleProps = Omit @@ -37,6 +37,7 @@ export const Stack: FC = ({ spacing = 2, alignItems, justifyContent, + ...divProps }) => { const styles = useStyles({ spacing, @@ -45,5 +46,9 @@ export const Stack: FC = ({ justifyContent, }) - return
{children}
+ return ( +
+ {children} +
+ ) } diff --git a/site/src/components/UserAvatar/UserAvatar.tsx b/site/src/components/UserAvatar/UserAvatar.tsx index d03041ddeca76..c861a983efa34 100644 --- a/site/src/components/UserAvatar/UserAvatar.tsx +++ b/site/src/components/UserAvatar/UserAvatar.tsx @@ -3,9 +3,9 @@ import { FC } from "react" import { firstLetter } from "../../util/firstLetter" export interface UserAvatarProps { - className?: string username: string - avatarURL: string + className?: string + avatarURL?: string } export const UserAvatar: FC = ({ username, className, avatarURL }) => { diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx index a266a70b2b3f2..81538af5352df 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -42,7 +42,13 @@ export const WorkspaceScheduleBanner: FC + } diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index ee439cbefec75..1b53583ed141c 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -230,6 +230,7 @@ export const WorkspaceScheduleForm: FC } label={Language.startSwitch} diff --git a/site/src/i18n/en/auditLog.json b/site/src/i18n/en/auditLog.json new file mode 100644 index 0000000000000..4d118ad85ada4 --- /dev/null +++ b/site/src/i18n/en/auditLog.json @@ -0,0 +1,7 @@ +{ + "actions": { + "create": "created a new", + "write": "updated", + "delete": "deleted" + } +} diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index 7d453dd427903..d6900f9463100 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -1,3 +1,4 @@ +import auditLog from "./auditLog.json" import common from "./common.json" import templatePage from "./templatePage.json" import workspacePage from "./workspacePage.json" @@ -5,5 +6,6 @@ import workspacePage from "./workspacePage.json" export const en = { common, workspacePage, + auditLog, templatePage, } diff --git a/site/src/pages/AuditPage/AuditPage.test.tsx b/site/src/pages/AuditPage/AuditPage.test.tsx index 8c98281207e86..4ddfb5119cad8 100644 --- a/site/src/pages/AuditPage/AuditPage.test.tsx +++ b/site/src/pages/AuditPage/AuditPage.test.tsx @@ -1,12 +1,18 @@ import { fireEvent, screen } from "@testing-library/react" -import { Language as CopyButtonLanguage } from "components/CopyButton/CopyButton" import { Language as AuditTooltipLanguage } from "components/Tooltips/AuditHelpTooltip" import { Language as TooltipLanguage } from "components/Tooltips/HelpTooltip/HelpTooltip" -import { render } from "testHelpers/renderHelpers" +import { MockAuditLog, MockAuditLog2, render } from "testHelpers/renderHelpers" +import * as CreateDayString from "util/createDayString" import AuditPage from "./AuditPage" import { Language as AuditViewLanguage } from "./AuditPageView" describe("AuditPage", () => { + beforeEach(() => { + // Mocking the dayjs module within the createDayString file + const mock = jest.spyOn(CreateDayString, "createDayString") + mock.mockImplementation(() => "a minute ago") + }) + it("renders a page with a title and subtitle", async () => { // When render() @@ -19,14 +25,12 @@ describe("AuditPage", () => { expect(await screen.findByText(AuditTooltipLanguage.title)).toBeInTheDocument() }) - it("describes the CLI command", async () => { + it("shows the audit logs", async () => { // When render() // Then - await screen.findByText("coder audit [organization_ID]") // CLI command; untranslated - const copyIcon = await screen.findByRole("button", { name: CopyButtonLanguage.ariaLabel }) - fireEvent.mouseOver(copyIcon) - expect(await screen.findByText(AuditViewLanguage.tooltipTitle)).toBeInTheDocument() + await screen.findByTestId(`audit-log-row-${MockAuditLog.id}`) + screen.getByTestId(`audit-log-row-${MockAuditLog2.id}`) }) }) diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 1ee1f465b67cf..342b0fbc8dc8a 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -1,9 +1,52 @@ +import { useMachine } from "@xstate/react" import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useNavigate, useSearchParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { auditMachine } from "xServices/audit/auditXService" import { AuditPageView } from "./AuditPageView" -// REMARK: This page is in-progress and hidden from users const AuditPage: FC = () => { - return + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const currentPage = searchParams.get("page") ? Number(searchParams.get("page")) : 1 + const [auditState, auditSend] = useMachine(auditMachine, { + context: { + page: currentPage, + limit: 25, + }, + actions: { + onPageChange: ({ page }) => { + navigate({ + search: `?page=${page}`, + }) + }, + }, + }) + const { auditLogs, count, page, limit } = auditState.context + + return ( + <> + + {pageTitle("Audit")} + + { + auditSend("NEXT") + }} + onPrevious={() => { + auditSend("PREVIOUS") + }} + onGoToPage={(page) => { + auditSend("GO_TO_PAGE", { page }) + }} + /> + + ) } export default AuditPage diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 7ad3725855264..f0275a6b9bae7 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -1,16 +1,29 @@ import { ComponentMeta, Story } from "@storybook/react" -import { AuditPageView } from "./AuditPageView" +import { MockAuditLog, MockAuditLog2 } from "testHelpers/entities" +import { AuditPageView, AuditPageViewProps } from "./AuditPageView" export default { title: "pages/AuditPageView", component: AuditPageView, } as ComponentMeta -const Template: Story = (args) => +const Template: Story = (args) => export const AuditPage = Template.bind({}) +AuditPage.args = { + auditLogs: [MockAuditLog, MockAuditLog2], + count: 1000, + page: 1, + limit: 25, +} export const AuditPageSmallViewport = Template.bind({}) +AuditPageSmallViewport.args = { + auditLogs: [MockAuditLog, MockAuditLog2], + count: 1000, + page: 1, + limit: 25, +} AuditPageSmallViewport.parameters = { chromatic: { viewports: [600] }, } diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index c284717fe2c9c..526b9890407ce 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -1,8 +1,18 @@ -import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { AuditLog } from "api/typesGenerated" +import { AuditLogRow } from "components/AuditLogRow/AuditLogRow" import { CodeExample } from "components/CodeExample/CodeExample" +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 { Stack } from "components/Stack/Stack" +import { TableLoader } from "components/TableLoader/TableLoader" import { AuditHelpTooltip } from "components/Tooltips" import { FC } from "react" @@ -12,48 +22,79 @@ export const Language = { tooltipTitle: "Copy to clipboard and try the Coder CLI", } -export const AuditPageView: FC = () => { - const styles = useStyles() +export interface AuditPageViewProps { + auditLogs?: AuditLog[] + count?: number + page: number + limit: number + onNext: () => void + onPrevious: () => void + onGoToPage: (page: number) => void +} + +export const AuditPageView: FC = ({ + auditLogs, + count, + page, + limit, + onNext, + onPrevious, + onGoToPage, +}) => { + const isLoading = auditLogs === undefined || count === undefined + const isEmpty = !isLoading && auditLogs.length === 0 + const hasResults = !isLoading && auditLogs.length > 0 return ( - - - - - {Language.title} - - - - {Language.subtitle} - - + } + > + + + {Language.title} + + + + {Language.subtitle} + + + + + + + Logs + + + + {isLoading && } + {hasResults && + auditLogs.map((auditLog) => )} + {isEmpty && ( + + + + + + )} + +
+
+ + {count && count > limit ? ( + -
+ ) : null}
) } - -const useStyles = makeStyles((theme) => ({ - headingContainer: { - marginTop: theme.spacing(6), - marginBottom: theme.spacing(5), - flexDirection: "row", - alignItems: "center", - - [theme.breakpoints.down("sm")]: { - flexDirection: "column", - alignItems: "start", - }, - }, - headingStyles: { - paddingTop: "0px", - paddingBottom: "0px", - }, - codeExampleStyles: { - height: "fit-content", - }, -})) diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index 9b9c7e1ad845b..8dfc2cb594384 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -88,7 +88,7 @@ export const TemplatePageView: FC <> + <> ) : ( createWorkspaceButton() )} - + } > diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3e3023f5ed228..28dd4372ae9a6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -760,3 +760,46 @@ export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { }, }, } + +export const MockAuditLog: TypesGen.AuditLog = { + id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", + request_id: "53bded77-7b9d-4e82-8771-991a34d759f9", + time: "2022-05-19T16:45:57.122Z", + organization_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", + ip: "127.0.0.1", + user_agent: + '"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"', + resource_type: "workspace", + resource_id: "ef8d1cf4-82de-4fd9-8980-047dad6d06b5", + resource_target: "bruno-dev", + resource_icon: "", + action: "create", + diff: {}, + status_code: 200, + additional_fields: "", + description: "Colin Adler updated the workspace bruno-dev", + user: MockUser, +} + +export const MockAuditLog2: TypesGen.AuditLog = { + ...MockAuditLog, + id: "53bded77-7b9d-4e82-8771-991a34d759f9", + action: "write", + diff: { + workspace_name: { + old: "old-workspace-name", + new: MockWorkspace.name, + secret: false, + }, + workspace_auto_off: { + old: true, + new: false, + secret: false, + }, + template_version_id: { + old: "fbd2116a-8961-4954-87ae-e4575bd29ce0", + new: "53bded77-7b9d-4e82-8771-991a34d759f9", + secret: false, + }, + }, +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index c9c79326e09a4..3c3b238dec1c6 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -154,4 +154,18 @@ export const handlers = [ rest.get("/api/v2/entitlements", (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockEntitlements)) }), + + // Audit + rest.get("/api/v2/audit", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + audit_logs: [M.MockAuditLog, M.MockAuditLog2], + }), + ) + }), + + rest.get("/api/v2/audit/count", (req, res, ctx) => { + return res(ctx.status(200), ctx.json({ count: 1000 })) + }), ] diff --git a/site/src/theme/overrides.ts b/site/src/theme/overrides.ts index dcc189fd62f53..8d3ac13f6425b 100644 --- a/site/src/theme/overrides.ts +++ b/site/src/theme/overrides.ts @@ -42,11 +42,17 @@ export const getOverrides = ({ palette, breakpoints }: Theme): Overrides => { boxShadow: "none", color: palette.text.primary, backgroundColor: colors.gray[17], + "&:hover": { boxShadow: "none", backgroundColor: colors.gray[17], borderColor: lighten(palette.divider, 0.2), }, + + "&.Mui-disabled": { + backgroundColor: palette.background.paper, + color: palette.secondary.main, + }, }, sizeSmall: { padding: `0 12px`, diff --git a/site/src/theme/palettes.ts b/site/src/theme/palettes.ts index e17f53fe6c6ee..6add858d4e8c5 100644 --- a/site/src/theme/palettes.ts +++ b/site/src/theme/palettes.ts @@ -13,7 +13,7 @@ export const darkPalette: PaletteOptions = { dark: colors.blue[9], }, secondary: { - main: colors.green[11], + main: colors.gray[11], contrastText: colors.gray[4], dark: colors.indigo[7], }, diff --git a/site/src/xServices/audit/auditXService.ts b/site/src/xServices/audit/auditXService.ts new file mode 100644 index 0000000000000..b26fcb1313583 --- /dev/null +++ b/site/src/xServices/audit/auditXService.ts @@ -0,0 +1,115 @@ +import { getAuditLogs, getAuditLogsCount } from "api/api" +import { getErrorMessage } from "api/errors" +import { AuditLog } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const auditMachine = createMachine( + { + id: "auditMachine", + schema: { + context: {} as { auditLogs?: AuditLog[]; count?: number; page: number; limit: number }, + services: {} as { + loadAuditLogsAndCount: { + data: { + auditLogs: AuditLog[] + count: number + } + } + }, + events: {} as + | { + type: "NEXT" + } + | { + type: "PREVIOUS" + } + | { + type: "GO_TO_PAGE" + page: number + }, + }, + tsTypes: {} as import("./auditXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + // Right now, XState doesn't a good job with state + context typing so + // this forces the AuditPageView to showing the loading state when the + // loading state is called again by cleaning up the audit logs data + entry: "clearPreviousAuditLogs", + invoke: { + src: "loadAuditLogsAndCount", + onDone: { + target: "success", + actions: ["assignAuditLogsAndCount"], + }, + onError: { + target: "error", + actions: ["displayApiError"], + }, + }, + onDone: "success", + }, + success: { + on: { + NEXT: { + actions: ["assignNextPage", "onPageChange"], + target: "loading", + }, + PREVIOUS: { + actions: ["assignPreviousPage", "onPageChange"], + target: "loading", + }, + GO_TO_PAGE: { + actions: ["assignPage", "onPageChange"], + target: "loading", + }, + }, + }, + error: { + type: "final", + }, + }, + }, + { + actions: { + clearPreviousAuditLogs: assign({ + auditLogs: (_) => undefined, + }), + assignAuditLogsAndCount: assign({ + auditLogs: (_, event) => event.data.auditLogs, + count: (_, event) => event.data.count, + }), + assignNextPage: assign({ + page: ({ page }) => page + 1, + }), + assignPreviousPage: assign({ + page: ({ page }) => page - 1, + }), + assignPage: assign({ + page: (_, { page }) => page, + }), + displayApiError: (_, event) => { + const message = getErrorMessage(event.data, "Error on loading audit logs.") + displayError(message) + }, + }, + services: { + loadAuditLogsAndCount: async ({ page, limit }, _) => { + const [auditLogs, count] = await Promise.all([ + getAuditLogs({ + // The page in the API starts at 0 + offset: (page - 1) * limit, + limit, + }).then((data) => data.audit_logs), + getAuditLogsCount().then((data) => data.count), + ]) + + return { + auditLogs, + count, + } + }, + }, + }, +) diff --git a/site/yarn.lock b/site/yarn.lock index 8af63ea04f0fc..f8e9c9751c857 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -3671,6 +3671,11 @@ dependencies: "@types/jest" "*" +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/uglify-js@*": version "3.17.0" resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.0.tgz#95271e7abe0bf7094c60284f76ee43232aef43b9" @@ -14246,6 +14251,11 @@ tzdata@1.0.30: resolved "https://registry.yarnpkg.com/tzdata/-/tzdata-1.0.30.tgz#d9d5a4b4b5e1ed95f6255f98c0564c4256316f52" integrity sha512-/0yogZsIRUVhGIEGZahL+Nnl9gpMD6jtQ9MlVtPVofFwhaqa+cFTgRy1desTAKqdmIJjS6CL+i6F/mnetrLaxw== +ua-parser-js@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775" + integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg== + uglify-js@^3.1.4: version "3.17.0" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.0.tgz#55bd6e9d19ce5eef0d5ad17cd1f587d85b180a85"