feat: Add Audit page in the UI (#3782)

This commit is contained in:
Bruno Quaresma
2022-09-07 17:26:12 -03:00
committed by GitHub
parent 1359850715
commit a00fdd699f
23 changed files with 739 additions and 74 deletions
+2
View File
@@ -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.36.2",
+11 -15
View File
@@ -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 = () => {
<Route
index
element={
process.env.NODE_ENV === "production" ? (
<Navigate to="/workspaces" />
) : (
<AuthAndFrame>
<RequirePermission
isFeatureVisible={
featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog)
}
>
<AuditPage />
</RequirePermission>
</AuthAndFrame>
)
<AuthAndFrame>
<RequirePermission
isFeatureVisible={
featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog)
}
>
<AuditPage />
</RequirePermission>
</AuthAndFrame>
}
></Route>
/>
</Route>
<Route path="settings" element={<SettingsLayout />}>
+17
View File
@@ -389,6 +389,23 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
return response.data
}
interface GetAuditLogsOptions {
limit: number
offset: number
}
export const getAuditLogs = async (
options: GetAuditLogsOptions,
): Promise<TypesGen.AuditLogResponse> => {
const response = await axios.get(`/api/v2/audit?limit=${options.limit}&offset=${options.offset}`)
return response.data
}
export const getAuditLogsCount = async (): Promise<TypesGen.AuditLogCountResponse> => {
const response = await axios.get(`/api/v2/audit/count`)
return response.data
}
export const getTemplateDAUs = async (
templateId: string,
): Promise<TypesGen.TemplateDAUsResponse> => {
@@ -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 (
<div className={styles.diff}>
<div className={combineClasses([styles.diffColumn, styles.diffOld])}>
{diffEntries.map(([attrName, valueDiff], index) => (
<div key={attrName} className={styles.diffRow}>
<div className={styles.diffLine}>{index + 1}</div>
<div className={styles.diffIcon}>-</div>
<div>
{attrName}:{" "}
<span className={combineClasses([styles.diffValue, styles.diffValueOld])}>
{getDiffValue(valueDiff.old)}
</span>
</div>
</div>
))}
</div>
<div className={combineClasses([styles.diffColumn, styles.diffNew])}>
{diffEntries.map(([attrName, valueDiff], index) => (
<div key={attrName} className={styles.diffRow}>
<div className={styles.diffLine}>{index + 1}</div>
<div className={styles.diffIcon}>+</div>
<div>
{attrName}:{" "}
<span className={combineClasses([styles.diffValue, styles.diffValueNew])}>
{getDiffValue(valueDiff.new)}
</span>
</div>
</div>
))}
</div>
</div>
)
}
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],
},
}))
@@ -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<typeof AuditLogRow>
const Template: Story<AuditLogRowProps> = (args) => (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell style={{ paddingLeft: 32 }}>Logs</TableCell>
</TableRow>
</TableHead>
<TableBody>
<AuditLogRow {...args} />
</TableBody>
</Table>
</TableContainer>
)
export const NoDiff = Template.bind({})
NoDiff.args = {
auditLog: MockAuditLog,
}
export const WithDiff = Template.bind({})
WithDiff.args = {
auditLog: MockAuditLog2,
defaultIsDiffOpen: true,
}
@@ -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<typeof Pill>["type"] => {
if (httpStatus >= 300 && httpStatus < 500) {
return "warning"
}
if (httpStatus >= 500) {
return "error"
}
return "success"
}
const actionLabelByAction: Record<AuditLog["action"], string> = {
create: t("actions.create", { ns: "auditLog" }),
write: t("actions.write", { ns: "auditLog" }),
delete: t("actions.delete", { ns: "auditLog" }),
}
const resourceLabelByResourceType: Record<AuditLog["resource_type"], string> = {
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<AuditLogRowProps> = ({
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 (
<TableRow key={auditLog.id} data-testid={`audit-log-row-${auditLog.id}`}>
<TableCell className={styles.auditLogCell}>
<Stack
style={{ cursor: shouldDisplayDiff ? "pointer" : undefined }}
direction="row"
alignItems="center"
className={styles.auditLogRow}
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter") {
toggle()
}
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
className={styles.auditLogRowInfo}
>
<Stack direction="row" alignItems="center">
<UserAvatar
username={auditLog.user?.username ?? ""}
avatarURL={auditLog.user?.avatar_url}
/>
<div>
<span className={styles.auditLogResume}>
<strong>{auditLog.user?.username}</strong> {readableActionMessage(auditLog)}{" "}
<strong>{auditLog.resource_target}</strong>
</span>
<span className={styles.auditLogTime}>{createDayString(auditLog.time)}</span>
</div>
</Stack>
<Stack
direction="column"
alignItems="flex-end"
spacing={1}
className={styles.auditLogRight}
>
<Pill
type={pillTypeByHttpStatus(auditLog.status_code)}
text={auditLog.status_code.toString()}
/>
<Stack direction="row" alignItems="center" className={styles.auditLogExtraInfo}>
<div>
<strong>IP</strong> {auditLog.ip}
</div>
<div>
<strong>OS</strong> {userAgent.os.name}
</div>
<div>
<strong>Browser</strong> {userAgent.browser.name} {userAgent.browser.version}
</div>
</Stack>
</Stack>
</Stack>
<div className={shouldDisplayDiff ? undefined : styles.disabledDropdownIcon}>
{isDiffOpen ? <CloseDropdown /> : <OpenDropdown />}
</div>
</Stack>
{shouldDisplayDiff && (
<Collapse in={isDiffOpen}>
<AuditLogDiff diff={auditLog.diff} />
</Collapse>
)}
</TableCell>
</TableRow>
)
}
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,
},
}))
+8 -3
View File
@@ -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<HTMLDivElement>
type StyleProps = Omit<StackProps, "className">
@@ -37,6 +37,7 @@ export const Stack: FC<StackProps & { children: ReactNode | ReactNode[] }> = ({
spacing = 2,
alignItems,
justifyContent,
...divProps
}) => {
const styles = useStyles({
spacing,
@@ -45,5 +46,9 @@ export const Stack: FC<StackProps & { children: ReactNode | ReactNode[] }> = ({
justifyContent,
})
return <div className={combineClasses([styles.stack, className])}>{children}</div>
return (
<div {...divProps} className={combineClasses([styles.stack, className])}>
{children}
</div>
)
}
@@ -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<UserAvatarProps> = ({ username, className, avatarURL }) => {
@@ -42,7 +42,13 @@ export const WorkspaceScheduleBanner: FC<React.PropsWithChildren<WorkspaceSchedu
return (
<Alert
action={
<Button color="inherit" disabled={isLoading} onClick={onExtend} size="small">
<Button
variant="outlined"
color="inherit"
disabled={isLoading}
onClick={onExtend}
size="small"
>
{Language.bannerAction}
</Button>
}
@@ -230,6 +230,7 @@ export const WorkspaceScheduleForm: FC<React.PropsWithChildren<WorkspaceSchedule
name="autoStartEnabled"
checked={form.values.autoStartEnabled}
onChange={handleToggleAutoStart}
color="primary"
/>
}
label={Language.startSwitch}
+7
View File
@@ -0,0 +1,7 @@
{
"actions": {
"create": "created a new",
"write": "updated",
"delete": "deleted"
}
}
+2
View File
@@ -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,
}
+11 -7
View File
@@ -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(<AuditPage />)
@@ -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(<AuditPage />)
// 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}`)
})
})
+45 -2
View File
@@ -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 <AuditPageView />
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 (
<>
<Helmet>
<title>{pageTitle("Audit")}</title>
</Helmet>
<AuditPageView
auditLogs={auditLogs}
count={count}
page={page}
limit={limit}
onNext={() => {
auditSend("NEXT")
}}
onPrevious={() => {
auditSend("PREVIOUS")
}}
onGoToPage={(page) => {
auditSend("GO_TO_PAGE", { page })
}}
/>
</>
)
}
export default AuditPage
@@ -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<typeof AuditPageView>
const Template: Story = (args) => <AuditPageView {...args} />
const Template: Story<AuditPageViewProps> = (args) => <AuditPageView {...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] },
}
+80 -39
View File
@@ -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<AuditPageViewProps> = ({
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 (
<Margins>
<Stack justifyContent="space-between" className={styles.headingContainer}>
<PageHeader className={styles.headingStyles}>
<PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.title}</span>
<AuditHelpTooltip />
</Stack>
</PageHeaderTitle>
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle>
</PageHeader>
<CodeExample
className={styles.codeExampleStyles}
tooltipTitle={Language.tooltipTitle}
code="coder audit [organization_ID]"
<PageHeader
actions={
<CodeExample tooltipTitle={Language.tooltipTitle} code="coder audit [organization_ID]" />
}
>
<PageHeaderTitle>
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.title}</span>
<AuditHelpTooltip />
</Stack>
</PageHeaderTitle>
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle>
</PageHeader>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell style={{ paddingLeft: 32 }}>Logs</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableLoader />}
{hasResults &&
auditLogs.map((auditLog) => <AuditLogRow auditLog={auditLog} key={auditLog.id} />)}
{isEmpty && (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available" />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{count && count > limit ? (
<PaginationWidget
prevLabel=""
nextLabel=""
onPrevClick={onPrevious}
onNextClick={onNext}
onPageClick={onGoToPage}
numRecords={count}
activePage={page}
numRecordsPerPage={limit}
/>
</Stack>
) : null}
</Margins>
)
}
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",
},
}))
@@ -88,7 +88,7 @@ export const TemplatePageView: FC<React.PropsWithChildren<TemplatePageViewProps>
<>
<PageHeader
actions={
<Stack direction="row" spacing={1}>
<>
<Link
underline="none"
component={RouterLink}
@@ -115,7 +115,7 @@ export const TemplatePageView: FC<React.PropsWithChildren<TemplatePageViewProps>
) : (
createWorkspaceButton()
)}
</Stack>
</>
}
>
<Stack direction="row" spacing={3} className={styles.pageTitle}>
+43
View File
@@ -758,3 +758,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,
},
},
}
+14
View File
@@ -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 }))
}),
]
+6
View File
@@ -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`,
+1 -1
View File
@@ -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],
},
+115
View File
@@ -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,
}
},
},
},
)
+10
View File
@@ -3676,6 +3676,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"
@@ -14251,6 +14256,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"