mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: Add Audit page in the UI (#3782)
This commit is contained in:
@@ -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
@@ -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 />}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"actions": {
|
||||
"create": "created a new",
|
||||
"write": "updated",
|
||||
"delete": "deleted"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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] },
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user